diff --git a/src/form/image-uploader.js b/src/form/image-uploader.js new file mode 100644 index 0000000..35b4883 --- /dev/null +++ b/src/form/image-uploader.js @@ -0,0 +1,607 @@ +/** + * {上传组件} + * @author yutent + * @date 2023/04/28 16:14:10 + */ + +import { css, html, Component, offset, bind, unbind, styleMap } from 'wkit' +import { parseSize } from '../base/fs.js' +import '../base/icon.js' + +const EV_OPTION = { once: true } +const DOC = document +const ROOT = document.documentElement + +function uuid() { + return Math.random().toString(16).slice(2) +} + +class Uploader extends Component { + static props = { + value: [], + accept: '*/*', + maxSize: 0, + disabled: false + } + + static styles = [ + css` + :host { + display: flex; + width: 100%; + font-size: 14px; + } + img, + a { + -webkit-user-drag: none; + } + + .container { + position: relative; + display: flex; + 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; + flex-direction: column; + justify-content: center; + align-items: center; + width: var(--size); + height: var(--size); + gap: 4px; + border: 1px solid var(--color-grey-2); + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: background 0.1s linear, border-color 0.1s linear, + color 0.1s linear; + + --wc-icon-size: 22px; + + &:hover { + border-color: var(--color-grey-1); + } + &:active { + background: var(--color-plain-1); + } + &.in { + border: 1px dashed var(--color-orange-1); + background: var(--color-drag-background); + } + } + .hidden-input { + position: absolute; + left: 0; + top: 0; + display: block; + width: 100%; + height: 100%; + opacity: 0; + cursor: inherit; + + &::-webkit-file-upload-button { + display: none; + } + } + `, + css` + .file-list { + display: flex; + gap: 12px; + + .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; + + &:hover { + background-color: var(--color-plain-1); + + wc-icon { + opacity: 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; + + &:hover { + border-color: rgba(255, 255, 255, 0.5); + } + } + } + } + `, + + // disabled + css` + :host([disabled]) { + opacity: 0.6; + + .upload-button { + background: var(--color-disabled-background); + cursor: not-allowed; + } + } + `, + // preview + css` + .preview { + position: fixed; + left: 0; + top: 0; + z-index: 99; + display: none; + align-items: center; + justify-content: center; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.3); + + img { + display: block; + max-width: 90%; + max-height: 90%; + object-fit: contain; + box-shadow: 0 0 20px #000; + } + } + `, + + //panel + css` + .edit-panel { + position: fixed; + left: 50%; + top: 50%; + z-index: 99; + align-items: center; + justify-content: center; + width: 800px; + height: 556px; + padding: 16px; + background: #fff; + box-shadow: 0 0 16px rgba(0, 0, 0, 0.3); + transform: translate(-50%, -50%); + -webkit-user-select: none; + user-select: none; + } + + .workspace { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 640px; + height: 480px; + + background: linear-gradient( + 45deg, + var(--color-grey-1) 25%, + transparent 25%, + transparent 75%, + var(--color-grey-1) 75%, + var(--color-grey-1) + ), + linear-gradient( + 45deg, + var(--color-grey-1) 25%, + transparent 25%, + transparent 75%, + var(--color-grey-1) 75%, + var(--color-grey-1) + ); + background-size: 16px 16px; + background-position: 0 0, 8px 8px; + box-shadow: 0 0 0 1px var(--color-grey-2); + + img { + max-width: 100%; + max-height: 100%; + } + } + + .crop-area { + position: absolute; + cursor: grab; + + &::before { + position: absolute; + left: -1px; + top: -1px; + width: calc(100% + 2px); + height: calc(100% + 2px); + border: 2px dashed var(--color-blue-1); + content: ''; + } + + &:active { + cursor: grabbing; + } + + i { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-blue-1); + cursor: move; + + &:nth-child(1) { + left: -4px; + top: -4px; + } + &:nth-child(2) { + right: -4px; + top: -4px; + } + &:nth-child(3) { + left: -4px; + bottom: -4px; + } + &:nth-child(4) { + right: -4px; + bottom: -4px; + } + } + } + + .statusbar { + position: absolute; + right: 16px; + top: 16px; + width: 112px; + + .crop { + display: flex; + flex-direction: column; + gap: 4px; + + span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 28px; + background: var(--color-plain-1); + cursor: pointer; + } + } + + fieldset { + margin-top: 8px; + padding: 3px 8px; + border: 1px solid var(--color-plain-2); + } + legend { + font-size: 12px; + } + } + .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; + } + } + ` + ] + + #files = [] + #timer + + #workspace = { + w: 640, + h: 480 + } + // 原图信息 + #origin = { + w: 670, + h: 429 + } + #cropData = { + x: 0, + y: 0, + w: 300, + h: 300 + } + #ratio = 0 + + #toast(msg) { + this.$refs.err.textContent = msg + clearTimeout(this.#timer) + this.#timer = setTimeout(() => { + this.$refs.err.textContent = '' + }, 5000) + } + + #fileChange(ev) { + let files = [...ev.target.files] + + ev.target.value = '' + + this.#fetchUpload(files) + } + + #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)}`) + } + } + + 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() + } + + #preview(ev, item) { + this.$refs.view.firstElementChild.src = + item.url || URL.createObjectURL(item.file) + this.$refs.view.style.display = 'flex' + } + + #closePreview(ev) { + this.$refs.view.style.display = '' + } + + #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 { w, h } = this.#cropData + + let ax = ev.x - left + let ay = ev.y - top + + ww -= w + hh -= h + + 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 + this.#cropData.x = x + this.#cropData.y = y + elem.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;` + }) + bind(DOC, 'mouseup', _ => unbind(DOC, 'mousemove', callback), EV_OPTION) + } + + #changeCropRatio(ev) { + if (ev.target === ev.currentTarget) { + return + } + let ratio = +ev.target.dataset.ratio + let { w: ww, h: hh } = this.#workspace + let { w, h } = 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 + } else { + } + + this.$refs.crop.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;` + } + } + + mounted() {} + + 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` +
+
+
+ + 选择图片 + +
+ +
+ +
+ ${this.#files.map( + it => html` +
+ this.#preview(ev, it)} + > + + this.#remove(ev, it, it.id)} + > +
+ ` + )} +
+
+
+ +
+
+
+ +
+ + + + +
+
+ + +
+ ` + } +} + +Uploader.reg('image-uploader') diff --git a/src/form/index.js b/src/form/index.js index 84738a3..aa175bc 100644 --- a/src/form/index.js +++ b/src/form/index.js @@ -12,3 +12,4 @@ import './star.js' import './switch.js' import './textarea.js' import './uploader.js' +import './image-uploader.js' diff --git a/src/form/uploader.js b/src/form/uploader.js index db79700..8c60e4a 100644 --- a/src/form/uploader.js +++ b/src/form/uploader.js @@ -171,8 +171,6 @@ class Uploader extends Component { gap: 6px; border: 1px solid var(--border-color); border-radius: 6px; - background: none no-repeat center; - background-size: contain; --wc-icon-size: 20px;