diff --git a/src/form/image-uploader.js b/src/form/image-uploader.js index 35b4883..5988cea 100644 --- a/src/form/image-uploader.js +++ b/src/form/image-uploader.js @@ -11,14 +11,25 @@ import '../base/icon.js' const EV_OPTION = { once: true } const DOC = document const ROOT = document.documentElement +const WORKSPACE_WIDTH = 640 +const WORKSPACE_HEIGHT = 480 +const WORKSPACE_RATIO = 640 / 480 function uuid() { return Math.random().toString(16).slice(2) } +function round(n) { + n = Math.round(n) + if (n % 2) { + n-- + } + return n +} + class Uploader extends Component { static props = { - value: [], + value: 'str!https://static.reduzixun.com/r-time/common/2cc153c8018.webp', accept: '*/*', maxSize: 0, disabled: false @@ -30,6 +41,8 @@ class Uploader extends Component { display: flex; width: 100%; font-size: 14px; + -webkit-user-select: none; + user-select: none; } img, a { @@ -42,30 +55,15 @@ class Uploader extends Component { width: 100%; padding: 6px 0; gap: 12px; - -webkit-user-select: none; - user-select: none; --size: var(--wc-uploader-size, 96px); --border-color: var(--wc-uploader-border-color, var(--color-grey-2)); --wc-icon-size: 14px; } - .error { - display: block; - line-height: 1.5; - font-size: 12px; - font-style: normal; - color: var(--color-red-1); - background: none; - } - - .limited { - display: none; - } - .upload-button { position: relative; - display: inline-flex; + display: flex; flex-direction: column; justify-content: center; align-items: center; @@ -108,49 +106,44 @@ class Uploader extends Component { } `, css` - .file-list { + .image-thumb { display: flex; - gap: 12px; + align-items: center; + justify-content: center; + width: var(--size); + height: var(--size); + padding: 0 8px; + line-height: 24px; + gap: 6px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: none no-repeat center; + background-size: contain; + transition: background 0.15s linear; - .item { - display: flex; - align-items: center; - justify-content: center; - width: var(--size); - height: var(--size); - padding: 0 8px; - line-height: 24px; - gap: 6px; - border: 1px solid var(--border-color); - border-radius: 6px; - background: none no-repeat center; - background-size: contain; - transition: background 0.15s linear; + --wc-icon-size: 20px; - --wc-icon-size: 20px; - - &:hover { - background-color: var(--color-plain-1); - - wc-icon { - opacity: 1; - } - } + &:hover { + background-color: var(--color-plain-1); wc-icon { - flex-shrink: 0; - padding: 3px; - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 50%; - background: rgba(64, 64, 64, 0.75); - color: #fff; - opacity: 0; - cursor: pointer; - transition: opacity 0.15s linear; + opacity: 1; + } + } - &:hover { - border-color: rgba(255, 255, 255, 0.5); - } + wc-icon { + flex-shrink: 0; + padding: 3px; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 50%; + background: rgba(64, 64, 64, 0.75); + color: #fff; + opacity: 0; + cursor: pointer; + transition: opacity 0.15s linear; + + &:hover { + border-color: rgba(255, 255, 255, 0.5); } } } @@ -194,27 +187,28 @@ class Uploader extends Component { //panel css` .edit-panel { - position: fixed; - left: 50%; - top: 50%; - z-index: 99; - align-items: center; - justify-content: center; + position: absolute; + left: 0; + top: 0; + z-index: 2; + display: none; width: 800px; height: 556px; padding: 16px; + border-radius: 6px; background: #fff; box-shadow: 0 0 16px rgba(0, 0, 0, 0.3); - transform: translate(-50%, -50%); -webkit-user-select: none; user-select: none; + + &.show { + display: flex; + flex-direction: column; + } } .workspace { position: relative; - display: flex; - align-items: center; - justify-content: center; width: 640px; height: 480px; @@ -238,9 +232,10 @@ class Uploader extends Component { background-position: 0 0, 8px 8px; box-shadow: 0 0 0 1px var(--color-grey-2); - img { - max-width: 100%; - max-height: 100%; + canvas { + display: block; + width: 100%; + height: 100%; } } @@ -319,39 +314,47 @@ class Uploader extends Component { legend { font-size: 12px; } + + .button { + width: 100%; + margin-top: 8px; + } } .toolbar { display: flex; gap: 6px; margin-top: 16px; + } - span { - display: inline-flex; - align-items: center; - justify-content: center; - height: 28px; - padding: 0 8px; - font-size: 12px; - border: 1px solid var(--color-grey-1); - border-radius: 4px; - background: var(--color-plain-1); - cursor: pointer; - } + .button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0 8px; + gap: 4px; + font-size: 12px; + border: 1px solid var(--color-grey-1); + border-radius: 4px; + background: var(--color-plain-1); + cursor: pointer; } ` ] - #files = [] - #timer + #ctx + #img + #panelShow = false - #workspace = { - w: 640, - h: 480 - } // 原图信息 #origin = { - w: 670, - h: 429 + x: 0, + y: 0, + w: 0, // 原图宽度 + h: 0, // 原图高度 + rw: 0, // 渲染宽度 + rh: 0, // 渲染高度 + scale: 1 //缩放比例 } #cropData = { x: 0, @@ -359,74 +362,39 @@ class Uploader extends Component { w: 300, h: 300 } - #ratio = 0 + #ratio = 1 - #toast(msg) { - this.$refs.err.textContent = msg - clearTimeout(this.#timer) - this.#timer = setTimeout(() => { - this.$refs.err.textContent = '' - }, 5000) + #stat = { + blur: false, + scalex: false, + scaley: false, + rotate: 0, + grey: false } #fileChange(ev) { - let files = [...ev.target.files] - + let file = ev.target.files[0] ev.target.value = '' - this.#fetchUpload(files) + this.#initPanel(URL.createObjectURL(file)) } - #fetchUpload(files) { - let { limit, maxSize } = this - let len = files.length - - if (maxSize > 0) { - files = files.filter(it => it.size <= maxSize) - if (files.length < len) { - len = files.length - this.#toast(`部分文件被忽略, 单文件最大允许 ${parseSize(maxSize)}`) + #fetchUpload(file) { + this.$emit('upload', { + file, + send: url => { + this.value = url + this.#preview() } - } - - if (limit > 0) { - files = files.slice(0, limit - this.#files.length) - if (files.length < len) { - this.#toast(`部分文件被忽略, 最多选择 ${limit} 个文件`) - } - } - - if (files.length) { - files = files.map(file => ({ file, id: uuid(), url: '', stat: 0 })) - - this.#files = this.#files.concat(files) - - this.$emit('upload', { - files, - send: args => { - for (let it of args) { - it.stat = 1 - } - this.value = this.#files.map(it => it.url) - } - }) - this.$requestUpdate() - } + }) } - #remove(ev, it, id) { - for (let i in this.#files) { - if (it === this.#files[i]) { - this.#files.splice(i, 1) - break - } - } - this.$requestUpdate() + #remove() { + this.value = '' } - #preview(ev, item) { - this.$refs.view.firstElementChild.src = - item.url || URL.createObjectURL(item.file) + #preview() { + this.$refs.view.firstElementChild.src = this.value this.$refs.view.style.display = 'flex' } @@ -434,25 +402,63 @@ class Uploader extends Component { this.$refs.view.style.display = '' } + #cancel() { + this.#panelShow = false + this.$requestUpdate() + } + + #confirm() { + this.#export().then(bin => { + this.#fetchUpload(bin) + // this.#cancel() + }) + } + + #download() { + this.#export().then(bin => { + let a = document.createElement('a') + a.href = URL.createObjectURL(bin) + a.download = 'image-cropped.png' + a.click() + this.#cancel() + }) + } + + #export() { + let { x, y, w, h } = this.#cropData + let data = this.#ctx.getImageData(x, y, w, h) + let can = new OffscreenCanvas(w, h) + let ctx = can.getContext('2d') + + ctx.putImageData(data, 0, 0) + return can.convertToBlob({ type: 'image/png', quality: 0.95 }) + } + #handleDrag(ev) { let elem = ev.currentTarget let outer = elem.parentNode let { top: _top, left: _left } = offset(outer) let { top, left } = offset(elem) - let { clientHeight: hh, clientWidth: ww } = outer + let { rw, rh } = this.#origin let { w, h } = this.#cropData let ax = ev.x - left let ay = ev.y - top + let ww = WORKSPACE_WIDTH + let hh = WORKSPACE_HEIGHT - ww -= w - hh -= h + let _x = (ww - rw) / 2 + let _y = (hh - rh) / 2 + + ww -= w + _x + hh -= h + _y let callback = bind(DOC, 'mousemove', ({ x, y }) => { x -= _left + ax y -= _top + ay - x = x < 0 ? 0 : x > ww ? ww : x - y = y < 0 ? 0 : y > hh ? hh : y + x = x < _x ? _x : x > ww ? ww : x + y = y < _y ? _y : y > hh ? hh : y + this.#cropData.x = x this.#cropData.y = y elem.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;` @@ -465,141 +471,196 @@ class Uploader extends Component { return } let ratio = +ev.target.dataset.ratio - let { w: ww, h: hh } = this.#workspace - let { w, h } = this.#origin + + let { rw, rh } = this.#origin let { x, y } = this.#cropData this.#ratio = ratio if (ratio > 0) { - let min = Math.min(w, h) - if (ratio < 1) { - } else if (ratio === 1) { - w = min - h = min - this.#cropData.w = w - this.#cropData.h = h + let min = Math.min(rw, rh) + if (ratio <= 1) { + rh = min + rw = round(rh * ratio) } else { + rw = min + rh = round(rw / ratio) } - this.$refs.crop.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;` + x = (WORKSPACE_WIDTH - rw) / 2 + y = (WORKSPACE_HEIGHT - rh) / 2 + + this.#cropData = { w: rw, h: rh, x, y } + + this.$refs.crop.style.cssText = `left:${x}px;top:${y}px;width:${rw}px;height:${rh}px;` } } - mounted() {} + #rotate() { + let ctx = this.#ctx + let { x, y, w, h, rw, rh } = this.#origin + + // ctx.save() + ctx.clearRect(0, 0, WORKSPACE_WIDTH, WORKSPACE_HEIGHT) + ctx.translate(WORKSPACE_WIDTH / 2, WORKSPACE_HEIGHT / 2) + ctx.rotate((90 * Math.PI) / 180) + ctx.drawImage(this.#img, x - w / 2, y - h / 2, rw, rh) + // ctx.restore() + console.log('<><><><>') + } + #scale() {} + + #filter(type) { + if (type === 'blur') { + this.#stat.blur = !this.#stat.blur + let { x, y, rw, rh } = this.#origin + this.#ctx.filter = this.#stat.blur ? 'blur(6px)' : 'none' + // this.#ctx.clearRect(0, 0, WORKSPACE_WIDTH, WORKSPACE_HEIGHT) + this.#ctx.drawImage(this.#img, x, y, rw, rh) + } + } + + #initPanel(url) { + let img = new Image() + + this.#img = img + this.#ctx.clearRect(0, 0, WORKSPACE_WIDTH, WORKSPACE_HEIGHT) + + img.onload = _ => { + let { naturalWidth: w, naturalHeight: h } = img + let scale = 1 + let ratio = w / h + let rw, rh + + if (ratio > WORKSPACE_RATIO) { + rw = WORKSPACE_WIDTH + scale = rw / w + rh = round(scale * h) + } else if (ratio === WORKSPACE_RATIO) { + rw = WORKSPACE_HEIGHT + rh = rw + scale = rw / w + } else { + rh = WORKSPACE_HEIGHT + scale = rh / h + rw = round(scale * w) + } + let x = (WORKSPACE_WIDTH - rw) / 2 + let y = (WORKSPACE_HEIGHT - rh) / 2 + this.#origin = { x, y, w, h, rw, rh, scale } + this.#ctx.drawImage(img, x, y, rw, rh) + this.#changeCropRatio({ target: { dataset: { ratio: 1 } } }) + this.#panelShow = true + this.$requestUpdate() + } + img.src = url + } + + mounted() { + this.#ctx = this.$refs.can.getContext('2d', { willReadFrequently: true }) + fetch(this.value) + .then(r => r.blob()) + .then(b => { + this.#initPanel(URL.createObjectURL(b)) + }) + } render() { let { disabled } = this - let limited = this.#files.length > 0 - let { w, h, x, y } = this.#cropData - let cropStyle = styleMap({ - width: w + 'px', - height: h + 'px', - left: x + 'px', - top: y + 'px' - }) - - console.log(cropStyle) return html`
-
-
- - 选择图片 - -
- -
- - + : html` +
+ + 选择图片 + +
+ `} + +
+
+ +
+ + + + +
+
+ + + +
+
-
-
- -
- - - - -
-
- - -
` } }