diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d02da7..cd1f809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,3 +24,4 @@ - add function for dragging icon into container - add homescreen function for storing apps' order - fix bugs of container +- add gauss blur component diff --git a/public/index.html b/public/index.html index 1010c03..256f79a 100644 --- a/public/index.html +++ b/public/index.html @@ -8,7 +8,7 @@ content="width=device-width, user-scalable=no, initial-scale=1.0" /> Star Web Components - + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 3f7056d..97b9938 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -21,6 +21,11 @@ "description": "StarWeb组件" } }, + "permissions": { + "settings": { + "access": "readwrite" + } + }, "core": true, "version": "0.0.1" } diff --git a/src/components/gauss_canvas/README.md b/src/components/gauss_canvas/README.md new file mode 100644 index 0000000..c425bff --- /dev/null +++ b/src/components/gauss_canvas/README.md @@ -0,0 +1,62 @@ +## 高斯模糊小组件 + +小组件为解决用 `Css` 属性进行高斯模糊后,浏览器卡顿的问题。原理是用一个 `canvas` 将根据不同 `sigma` 值进行高斯模糊处理后的图展示出来。 + +该组件的模糊算法当 `sigma` 越高时 ,其处理模糊的速度越快,越低时处理速度越慢,然而 `sigma` 越低,模糊程度越小,与原图越类似,可以用降低分辨率的方法替换模糊算法。因此组件设定了一个门槛值 `threshold` ,使用者可以根据机器性能和图片质量选择门槛值的高低。 + +因为模糊算法耗时受 `sigma` 值和图片分辨率的影响,无法准确掌控,因此为了不阻塞主线程的渲染工作,模糊算法需要放入 `Web worker` 中运行。 + +### 属性 + +- `src`: 要高斯模糊的图片URL +- `sigma`: 高斯模糊程度系数,除了第一次传值时不会有模糊渐变,之后传值时图片模糊会呈渐变,如若不需要动画则调用方法 `showImmediately` 并传值 +- `threshold`: 当 `sigma` 大于该值时,组件采用模糊算法,否则采用降低分辨率的方法模糊图片,默认值为1 +- `during`: 模糊渐变的最长时间,单位为 `ms`,默认值为 500 +- `bezier`: 模糊渐变的贝塞尔系数,接受参数为一个有四个数字元素的数组,默认值为 `[0.19, 1, 0.22, 1]` + +### 使用 + +注意,请不要使用跨域图片资源,否则无法转化为 `ImageData` 进行模糊计算 + + +```html + +``` + +```js +import "@star-web-element/gauss" +const canvas = document.querySelector('gauss-canvas'); + +canvas.addEventListener('click', () => { + // 会有模糊渐变 + canvas.sigma ^= 10 +}) + +canvas.addEventListener('mousedown', handleEvent); +canvas.addEventListener('mousemove', handleEvent); +canvas.addEventListener('mouseup', handleEvent); + +let mouseData = { + start: 0, + moveDistance: 0, +} +function handleEvent(evt) { + switch (evt.type) { + case 'mousedown': + mouseData.start = evt.clientY; + break; + case ' mousemove': + if (mouseData.start) { + mouseData.moveDistance = evt.clientY - mouseData.start; + const conHeight = canvas.parentElement.offsetHeight; + const targetSigma = (mouseData.moveDistance / conHeight) * 10; + // 不会有模糊渐变 + canvas.showImmediately(targetSigma); + } + break; + case 'mouseup': + mouseData.start = mouseData.moveDistance = 0; + break; + } +} +``` diff --git a/src/components/gauss_canvas/index.ts b/src/components/gauss_canvas/index.ts new file mode 100644 index 0000000..23ce167 --- /dev/null +++ b/src/components/gauss_canvas/index.ts @@ -0,0 +1,287 @@ +import {html, css, LitElement} from 'lit' +import {customElement, property, query} from 'lit/decorators.js' +import workerUrl from './worker' +enum ErrorType { + IMAGE_LOAD_FAILED = 'Image load failed!', + IMAGE_NOT_PREPARED = 'The Image resource is not completely prepared!', + IMAGE_CROS_ERROR = 'The Image resource crosses domain!', + CANVAS_PUTIMAGEDATA_ERROR = 'Canvas putImageData failed!', +} + +const cubic_bezier = ( + p0: number, + p1: number, + p2: number, + p3: number, + t: number +): number => { + return ( + p0 * Math.pow(1 - t, 3) + + 3 * p1 * t * Math.pow(1 - t, 2) + + 3 * p2 * t * t * (1 - t) + + p3 * Math.pow(t, 3) + ) +} + +@customElement('gauss-canvas') +export default class GaussCanvas extends LitElement { + @query('#display') display!: HTMLCanvasElement + @property({type: String}) + get src() { + return this._src + } + set src(value) { + if (value && value !== this._src) { + const loadCb = () => { + this._src = value + } + const errCb = () => { + this._img.src = this._src + this._img.removeEventListener('load', loadCb) + } + + this._img.addEventListener('error', errCb, {once: true}) + this._img.addEventListener('load', loadCb, {once: true}) + + this._img.src = value + } + } + + @property() threshold: number = 1 + @property() transition: boolean = false + + @property() + get sigma() { + return this._sigma + } + set sigma(value) { + if (value < 0) { + value = 0 + } + if (this._sigma == -1) { + this._sigma = Number(value) + this.show(value) + } else if (value !== this._sigma && this._targetSigma == -1) { + this._targetSigma = value + this.openAnimation() + } else { + console.info('changed target sigma') + } + } + + @property({type: Number}) bezier: [number, number, number, number] = [ + 0.19, 1, 0.22, 1, + ] + @property() during = 500 + + readonly _img: HTMLImageElement = document.createElement('img') + _loadStatus: boolean = false + _imgWidth: number = 0 + _imgHeight: number = 0 + + readonly _trCanvas: HTMLCanvasElement = document.createElement('canvas') + readonly _trCtx = this._trCanvas.getContext('2d')! + _imageData: ImageData | undefined + + _src: string = '' + _ctx!: CanvasRenderingContext2D + _sigma: number = -1 + _targetSigma: number = -1 + + _worker: Worker | undefined + _id!: number + + disWidth: number = 0 + disHeight: number = 0 + + constructor(url: string) { + super() + this._img.addEventListener('error', this.loadError) + this._img.addEventListener('load', this.loadSuccess) + this._id = new Date().getTime() + this.src = url + } + + loadError = () => { + this._loadStatus = false + this._imgWidth = this._imgHeight = 0 + + console.error(ErrorType.IMAGE_LOAD_FAILED) + } + + loadSuccess = () => { + this._loadStatus = true + this._imgHeight = this._img.height + this._imgWidth = this._img.width + this.show(this.sigma) + } + + drawWorker = ( + sigma: number, + imageData: ImageData, + canvas: HTMLCanvasElement, + draw: Function + ) => { + if (sigma > this.threshold) { + if (!this._worker) { + this._worker = new Worker(workerUrl) + this._worker.addEventListener('message', (evt) => { + draw(evt.data.data, evt.data.id) + }) + } + + this._worker.postMessage({ + imageData, + sigma, + id: this._id, + height: canvas.height, + width: canvas.width, + }) + this.isFormatting = true + } else { + draw(imageData, this._id) + this.cb?.() + } + } + + cb: Function | undefined + isFormatting: boolean = false + show = (sigma: number, cb?: Function) => { + this.cb = cb + return new Promise((res, rej) => { + if (!this._loadStatus) { + if (!this._img.src.includes(this._src)) { + this._img.src = this._src + } + + return + } + + if (this.isFormatting) { + return res(void 0) + } + let ratio: number = sigma + if (sigma == 0) { + ratio = 1 + } else if (sigma < this.threshold) { + ratio = 2 * this.threshold - 1 + } + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + canvas.height = this._img.height / ratio + canvas.width = this._img.width / ratio + + ctx.drawImage( + this._img, + 0, + 0, + this._img.width, + this._img.height, + 0, + 0, + canvas.width, + canvas.height + ) + + const draw = (imageData: ImageData, dataId: number) => { + if (dataId == this._id) { + if (this.cb) { + this.cb?.() + } + // +100 为了清除拖影 + this.disHeight = this.display.height = imageData.height /* + 100 */ + this.disWidth = this.display.width = imageData.width + this._ctx.putImageData(imageData, 0, 0) + this.isFormatting = false + res(void 0) + } + } + try { + let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + this.drawWorker(sigma, imageData, canvas, draw) + } catch (error: any) { + if (error.code == 18) { + return rej(ErrorType.IMAGE_CROS_ERROR) + } + return rej(ErrorType.CANVAS_PUTIMAGEDATA_ERROR) + } + }) + } + + showImmediately(sigma: number) { + return new Promise((res) => { + this._sigma = sigma + this.show(sigma).then(res) + }) + } + + _startTime!: number + animation: Function | undefined + openAnimation() { + this._startTime = new Date().getTime() + const getCurSigma = () => { + const t = (new Date().getTime() - this._startTime) / this.during + let ratio = cubic_bezier(...this.bezier, t) + + if (ratio > 0.95) { + return this._targetSigma + } + const result = (this._targetSigma - this._sigma) * ratio + this._sigma + + return result > 0 ? result : 0 + } + let changeSigma = () => { + this._sigma = getCurSigma() + + if (this._targetSigma !== this._sigma) { + return true + } else { + return false + } + } + this.animation = () => { + if (changeSigma()) { + // console.time(`sigma: ${this._sigma}`) + this.show(this._sigma, () => { + // console.timeEnd(`sigma: ${this._sigma}`) + // @ts-ignore + requestAnimationFrame(this.animation!) + }) + } else { + this._targetSigma = -1 + } + } + + this.animation() + } + + protected firstUpdated() { + this._ctx = this.display.getContext('2d')! + } + + render() { + return html` + + ` + } + + static styles = css` + :host { + display: block; + overflow: hidden; + } + canvas { + display: block; + margin: auto; + height: calc(100%); + width: 100%; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'gauss-canvas': GaussCanvas + } +} diff --git a/src/components/gauss_canvas/package.json b/src/components/gauss_canvas/package.json new file mode 100644 index 0000000..f852ed3 --- /dev/null +++ b/src/components/gauss_canvas/package.json @@ -0,0 +1,25 @@ +{ + "name": "@star-web-components/gauss", + "version": "0.0.1", + "description": "", + "type": "module", + "main": "./index.js", + "module": "./index.js", + "exports": { + ".": { + "default": "./index.js" + }, + "./index": { + "default": "./index.js" + }, + "./index.js": { + "default": "./index.js" + }, + "./icons/index.js": { + "default": "./icons/index.js" + }, + "./package.json": "./package.json" + }, + "author": "", + "license": "ISC" +} diff --git a/src/components/gauss_canvas/worker.ts b/src/components/gauss_canvas/worker.ts new file mode 100644 index 0000000..978be46 --- /dev/null +++ b/src/components/gauss_canvas/worker.ts @@ -0,0 +1,181 @@ +// import { +// cubic_bezier, +// fastBlur, +// genSrcAndDest, +// mergeChannels, +// genKernelsForGaussian, +// _fastBlur, +// hFastMotionBlur, +// vFastMotionBlur, +// } from './utils' +// @ts-ignore +const workCode = () => { + function genKernelsForGaussian(sigma: number, n: number) { + const wIdeal = Math.sqrt((12 * Math.pow(sigma, 2)) / n + 1) + const sizes = [] + let wl = Math.floor(wIdeal) + + if (wl % 2 === 0) { + wl-- + } + const wu = wl + 2 + let m = + (12 * Math.pow(sigma, 2) - n * Math.pow(wl, 2) - 4 * n * wl - 3 * n) / + (-4 * wl - 4) + m = Math.round(m) + + for (let i = 0; i < n; i++) { + sizes.push(i < m ? wl : wu) + } + + return sizes + } + + function genSrcAndDest(data: Uint8ClampedArray) { + const dataInt8 = new Uint8ClampedArray(data.buffer) + const simpleChannelLength = dataInt8.length / 4 + const r = new Uint8ClampedArray(simpleChannelLength) + const g = new Uint8ClampedArray(simpleChannelLength) + const b = new Uint8ClampedArray(simpleChannelLength) + const a = new Uint8ClampedArray(simpleChannelLength) + const _r = new Uint8ClampedArray(simpleChannelLength) + const _g = new Uint8ClampedArray(simpleChannelLength) + const _b = new Uint8ClampedArray(simpleChannelLength) + const _a = new Uint8ClampedArray(simpleChannelLength) + for (let i = 0; i < simpleChannelLength; i++) { + _r[i] = r[i] = dataInt8[i * 4] + _g[i] = g[i] = dataInt8[i * 4 + 1] + _b[i] = b[i] = dataInt8[i * 4 + 2] + _a[i] = a[i] = dataInt8[i * 4 + 3] + } + return {src: [r, g, b, a], dest: [_r, _g, _b, _a]} + } + + function mergeChannels([r, g, b, a]: Uint8ClampedArray[]) { + const simpleChannelLength = r.length + const data = new Uint8ClampedArray(simpleChannelLength * 4) + for (let i = 0; i < simpleChannelLength; i++) { + data[4 * i] = r[i] + data[4 * i + 1] = g[i] + data[4 * i + 2] = b[i] + data[4 * i + 3] = a[i] + } + return data + } + + function hFastMotionBlur( + src: Uint8ClampedArray, + dest: Uint8ClampedArray, + width: number, + height: number, + radius: number + ) { + for (let i = 0; i < height; i++) { + let accumulation = radius * src[i * width] + for (let j = 0; j <= radius; j++) { + accumulation += src[i * width + j] + } + + dest[i * width] = Math.round(accumulation / (2 * radius + 1)) + + for (let j = 1; j < width; j++) { + const left = Math.max(0, j - radius - 1) + const right = Math.min(width - 1, j + radius) + accumulation = + accumulation + (src[i * width + right] - src[i * width + left]) + dest[i * width + j] = Math.round(accumulation / (2 * radius + 1)) + } + } + } + + function vFastMotionBlur( + src: Uint8ClampedArray, + dest: Uint8ClampedArray, + width: number, + height: number, + radius: number + ) { + for (let i = 0; i < width; i++) { + let accumulation = radius * src[i] + for (let j = 0; j <= radius; j++) { + accumulation += src[j * width + i] + } + + dest[i] = Math.round(accumulation / (2 * radius + 1)) + + for (let j = 1; j < height; j++) { + const top = Math.max(0, j - radius - 1) + const bottom = Math.min(height - 1, j + radius) + accumulation = + accumulation + src[bottom * width + i] - src[top * width + i] + dest[j * width + i] = Math.round(accumulation / (2 * radius + 1)) + } + } + } + + function _fastBlur( + src: Uint8ClampedArray, + dest: Uint8ClampedArray, + width: number, + height: number, + radius: number + ) { + hFastMotionBlur(dest, src, width, height, radius) + vFastMotionBlur(src, dest, width, height, radius) + } + + function fastBlur( + src: Uint8ClampedArray, + dest: Uint8ClampedArray, + width: number, + height: number, + sigma: number + ) { + const boxes = genKernelsForGaussian(sigma, 3) + + for (let i = 0; i < src.length; i++) { + dest[i] = src[i] + } + + _fastBlur(src, dest, width, height, (boxes[0] - 1) / 2) + _fastBlur(src, dest, width, height, (boxes[1] - 1) / 2) + _fastBlur(src, dest, width, height, (boxes[2] - 1) / 2) + + return dest + } + + onmessage = (evt) => { + postMessage({data: blurCanvas(evt.data), id: evt.data.id}) + } + let blurCanvas = ({ + imageData, + height, + width, + sigma, + }: { + imageData: ImageData + sigma: number + height: number + width: number + }) => { + const {src: srcRgba, dest: destRgba} = genSrcAndDest(imageData.data) + + for (let i = 0; i < 3; i++) { + fastBlur(srcRgba[i], destRgba[i], width, height, sigma) + } + const destData = mergeChannels(destRgba) + imageData.data.set(destData) + return imageData + } +} + +const transfer = () => { + let codeStr: string = '' + + codeStr += `(${workCode.toString()})()` + return codeStr +} + +let workBlob = new Blob([transfer()]) + +export default URL.createObjectURL(workBlob) diff --git a/src/test/panels/activeoverlay/activeoverlay.ts b/src/test/panels/activeoverlay/activeoverlay.ts index 42be2cc..65bd198 100644 --- a/src/test/panels/activeoverlay/activeoverlay.ts +++ b/src/test/panels/activeoverlay/activeoverlay.ts @@ -1,5 +1,5 @@ import {html, LitElement, css} from 'lit' -import {customElement, property} from 'lit/decorators.js' +import {customElement} from 'lit/decorators.js' import '../../../components/button/button' import '../../../components/overlay/active-overlay' import {OverlayStack} from '../../../components/overlay/overlay-stack' diff --git a/src/test/panels/digicipher/digicipher.ts b/src/test/panels/digicipher/digicipher.ts index 2014cf8..34b61a9 100644 --- a/src/test/panels/digicipher/digicipher.ts +++ b/src/test/panels/digicipher/digicipher.ts @@ -1,5 +1,5 @@ import {html, LitElement, css} from 'lit' -import {customElement, property} from 'lit/decorators.js' +import {customElement} from 'lit/decorators.js' import '../icon/icon' @customElement('panel-digicipher') diff --git a/src/test/panels/gauss_canvas/gauss-blur.ts b/src/test/panels/gauss_canvas/gauss-blur.ts new file mode 100644 index 0000000..34b9ddc --- /dev/null +++ b/src/test/panels/gauss_canvas/gauss-blur.ts @@ -0,0 +1,127 @@ +import {html, css, LitElement, CSSResultGroup} from 'lit' +import {customElement, property, query} from 'lit/decorators.js' +import '../../../components/gauss_canvas/index' +import GaussCanvas from '../../../components/gauss_canvas/index' + +@customElement('panel-gauss') +export class GaussBlur extends LitElement { + @query('#container') container!: HTMLDivElement + @query('gauss-canvas') canvas!: GaussCanvas + // _src = '/src/test/panels/gauss_canvas/big.jpeg' + @property() _src!: string + // 'https://fanyiapp.cdn.bcebos.com/cms/image/cfacf96e5beb2a8444e016b96fb96ab6.jpg' + @property({type: Number}) sigma: number = 2 + @property({type: Number}) topPositionSigma: number = 20 + @property({type: Number}) bottomPositionSigma: number = 2 + + _moveFlag: boolean = false + _offsetY: number = 0 + _start: number = 0 + _moveDistance: number = 0 + set offsetY(value: number) { + this._offsetY = -value + this.canvas.display.style.transform = `translateY(${value}px)` + } + + handleEvent = (evt: TouchEvent | MouseEvent) => { + switch (evt.type) { + case 'touchstart': + case 'mousedown': + this._moveFlag = false + if (evt instanceof MouseEvent) { + this._start = evt.clientY + } else { + this._start = evt.touches[0].pageY + } + break + case 'touchmove': + case 'mousemove': + if (this._start) { + if (evt instanceof MouseEvent) { + this._moveDistance = evt.clientY - this._start + } else { + this._moveDistance = evt.touches[0].pageY - this._start + } + + if (this._moveDistance > 0) this._moveDistance = 0 + + requestAnimationFrame(() => { + this.offsetY = this._moveDistance + this.changeSigma() + }) + } + this._moveFlag = true + + break + case 'touchend': + case 'mouseup': + this._start = this._moveDistance = 0 + break + case 'click': + !this._moveFlag && (this.canvas.sigma ^= 10) + break + } + } + + /** + * change sigma fowllowing this.offsetY + */ + changeSigma() { + const conHeight = this.container.offsetHeight + const targetSigma = + (this._offsetY / conHeight) * + (this.topPositionSigma - this.bottomPositionSigma) + + this.bottomPositionSigma + this.canvas.showImmediately(targetSigma) + } + + firstUpdated() { + ;(window as any).panel = this + + this.addEventListener('touchstart', this) + this.addEventListener('touchmove', this) + this.addEventListener('touchend', this) + this.addEventListener('mousedown', this) + this.addEventListener('mousemove', this) + this.addEventListener('mouseup', this) + this.canvas.addEventListener('click', this) + } + handleInputFile(evt: Event) { + const imgfile = (evt.target as HTMLInputElement).files?.[0] + if (imgfile) { + this._src = URL.createObjectURL(imgfile) + } + } + render() { + return html` +
+ + +
+ ` + } + + static styles?: CSSResultGroup | undefined = css` + #container { + height: 100vh; + width: 100vw; + will-change: transform; + overflow: hidden; + } + input { + position: absolute; + top: 0; + left: 0; + } + gauss-canvas { + height: 100vh; + width: 100vw; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'panel-gauss': GaussBlur + } +} diff --git a/src/test/panels/overflowmenu/overflowmenu.ts b/src/test/panels/overflowmenu/overflowmenu.ts index bc27d84..267004e 100644 --- a/src/test/panels/overflowmenu/overflowmenu.ts +++ b/src/test/panels/overflowmenu/overflowmenu.ts @@ -1,5 +1,5 @@ import {html, LitElement, css, CSSResultArray} from 'lit' -import {customElement, property} from 'lit/decorators.js' +import {customElement} from 'lit/decorators.js' import '../../../components/button/button' import '../../../components/ul/ul' import '../../../components/li//li' diff --git a/src/test/panels/root.ts b/src/test/panels/root.ts index f8a6115..1bef929 100644 --- a/src/test/panels/root.ts +++ b/src/test/panels/root.ts @@ -14,6 +14,7 @@ import './card/card' import './indicators/indicators' import './indicators/home-indicator' import './blur/use-blur' +import './gauss_canvas/gauss-blur' import './button/button' import './container/container' import './radio/radio' @@ -249,6 +250,14 @@ export class PanelRoot extends LitElement { href="#blur" >
+ +