/** * {} * @author yutent * @date 2023/09/14 16:49:15 */ import { css, raw, html, Component, bind, unbind, nextTick, styleMap, classMap, outsideClick, clearOutsideClick } from 'wkit' import { renderToolbar, DEFAULT_TOOLS, html2md } from './helper.js' import Addon from './addon.js' import markd from '../markd/index.js' import '../form/input.js' import '../form/button.js' import '../code/index.js' const COLORS = [ '#f3f5fb', '#dae1e9', '#62778d', '#58d68d', '#3fc2a7', '#52a3de', '#ac61ce', '#ffb618', '#e67e22', '#ff5061', '#ff0000', '#000000' ] // 获取一维数组转二维的行 function getY(i) { return (i / 9) >> 0 } //获取一维数组转二维的列 function getX(i) { return i % 9 } function getIndex(x, y) { return x + y * 9 } class MEditor extends Component { static watches = ['value'] static props = { toolbar: { type: String, default: null, attribute: false, observer(v) { if (v === null) { this.#toolbar = [...DEFAULT_TOOLS] } else { this.#toolbar = v .split(',') .filter(it => it) .map(it => it.trim()) } } }, readonly: false, disabled: false } static styles = [ css` :host { display: flex; min-width: 200px; max-height: 720px; border-radius: 3px; transition: box-shadow 0.15s linear; background: var(--wc-meditor-background, #fff); } .noselect { -webkit-touch-callout: none; -webkit-user-select: none; -moz-user-select: none; user-select: none; } `, css` .meditor { position: relative; flex: 1; display: flex; flex-direction: column; width: 100%; border: 1px solid var(--wc-editor-border-color, var(--color-grey-2)); border-radius: inherit; font-size: 14px; background: #fff; } .toolbar { display: none; width: 100%; height: 34px; padding: 5px; line-height: 24px; border-bottom: 1px solid var(--color-grey-1); span { position: relative; overflow: hidden; display: flex; justify-content: center; align-items: center; width: 24px; height: 24px; margin: 0 3px; border-radius: 3px; color: var(--color-grey-3); .icon { overflow: hidden; width: 70%; height: 70%; fill: currentColor; color: #62778d; } &:hover, &.active { background: var(--color-plain-1); } &.active { color: var(--color-teal-1); } } &.active { display: flex; } } `, css` .editor-outbox { flex: 1; display: flex; width: 100%; min-height: 300px; border-radius: 3px; .editor, .preview { flex: 1; flex-shrink: 0; } .editor { height: 100%; padding: 5px 8px; line-height: 1.5; border: 0; font-size: 14px; font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; color: var(--color-dark-1); background: none; outline: none; resize: none; cursor: inherit; &::placeholder { color: var(--color-grey-1); } } .preview { overflow: hidden; overflow-y: auto; display: none; padding: 6px 12px; border-left: 1px solid var(--color-plain-2); &.active { display: block; } } } `, css` :host([readonly]) { .editor { cursor: default; opacity: 0.8; } } :host([disabled]) { .editor { cursor: not-allowed; opacity: 0.6; } } :host([readonly]), :host([disabled]) { .toolbar { span:hover { background: none; } } } :host(:focus-within) { box-shadow: 0 0 0 2px var(--color-plain-a); } :host(.fullscreen) { position: fixed; top: 0; left: 0; z-index: 9; width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; } `, css` .addon-table { display: flex; flex-direction: column; justify-content: space-between; width: 222px; height: 222px; padding: 2px; background: #fff; li { display: flex; justify-content: space-between; height: 20px; span { width: 20px; height: 20px; background: var(--color-plain-1); &.active { background: rgba(77, 182, 172, 0.3); } } } } .addon-header { width: 108px; height: 190px; padding: 5px 0; line-height: 30px; user-select: none; background: #fff; li { display: flex; align-items: center; width: 100%; height: 30px; padding: 0 12px; transition: background 0.1s ease-in-out; cursor: pointer; .icon { width: 14px; height: 14px; margin-right: 8px; } &:hover { background: var(--color-plain-1); } } } .addon-link { width: 320px; padding: 8px 5px; background: #fff; font-size: 13px; li { display: flex; align-items: center; padding: 0 12px; margin-top: 6px; label { width: 60px; margin-right: 8px; } wc-input { flex: 1; } wc-button { width: 80px; } } } .addon-attach { width: 320px; padding: 8px 5px; background: #fff; font-size: 13px; .tabs { display: flex; border-bottom: 1px solid var(--color-plain-2); user-select: none; span { height: 28px; padding: 0 8px; line-height: 28px; cursor: pointer; &.active { color: var(--color-teal-1); } } } .remote, .locale { display: none; &.active { display: block; } } .locale { height: 120px; padding: 24px 32px; .button { position: relative; width: 100%; height: 100%; padding: 12px 16px; line-height: 46px; border: 1px dashed var(--color-plain-3); border-radius: 4px; text-align: center; cursor: pointer; transition: background 0.1s ease-in-out; &::after { content: '点击选择文件,或拖拽文件到此处'; } &:hover, &.active { background: rgba(255, 228, 196, 0.15); } } input { position: absolute; left: 0; top: 0; z-index: 0; width: 100%; height: 100%; opacity: 0; } } li { display: flex; align-items: center; padding: 0 12px; margin-top: 6px; label { width: 60px; margin-right: 8px; } wc-input { flex: 1; } wc-button { width: 80px; } } } ` ] get value() { try { return this.$refs.editor.value } catch (err) { console.log(err) return '' } } set value(val) { if (this.$refs.editor) { if (this.$refs.editor.value === val) { return } this.$refs.editor.value = val this.#updatePreview() this.$emit('input') } else { nextTick(_ => (this.value = val)) } } #toolbar = [...DEFAULT_TOOLS] #value = '' #cache = { bar: 0, y: 0 } #gridx = 0 #gridy = 0 #select = null previewEnabled = false __init__() { // let { outer, inner, thumb } = this.$refs let height = outer.offsetHeight let scrollHeight = inner.scrollHeight + 10 let bar = 50 // 滚动条的高度 bar = (height * (height / scrollHeight)) >> 0 if (bar < 50) { bar = 50 } // 100%或主体高度比滚动条还短时不显示 if (bar >= height) { bar = 0 } this.#cache.bar = bar thumb.style.height = bar + 'px' } #fetchScroll(moveY) { let { bar } = this.#cache let { outer, thumb, inner } = this.$refs let height = outer.offsetHeight let scrollHeight = inner.scrollHeight + 10 if (moveY < 0) { moveY = 0 } else if (moveY > height - bar) { moveY = height - bar } inner.scrollTop = (scrollHeight - height) * (moveY / (height - bar)) thumb.style.transform = `translateY(${moveY}px)` return moveY } #hideLayers() { this.$refs.font.classList.remove('fadein') this.$refs.color.classList.remove('fadein') this.$refs.link.classList.remove('fadein') this.$refs.table.classList.remove('fadein') } // 处理图片 #handleFile(ev, file, t = '!') { if (ev && ev.type === 'change') { file = ev.target.files[0] ev.target.value = '' } this.$emit('upload', { detail: { file, send: link => { this.$refs.editor.focus() this.insert(`${t}[${file.name}](${link})`) } } }) } #handlePaste(ev) { let html = ev.clipboardData.getData('text/html') let txt = ev.clipboardData.getData('text/plain') let items = ev.clipboardData.items if (this.readOnly || this.disabled) { return } // 先文件判断, 避免右键单击复制图片时, 当成html处理 if (items && items.length) { let file = null for (let it of items) { file = it.getAsFile() if (file) { break } } if (file) { return this.#handleFile( null, file, file.type.includes('image') ? '!' : '' ) } } if (html) { this.insert(html2md(html)) } else if (txt) { this.insert(txt) } } #updatePreview() { if (this.previewEnabled) { this.$refs.view.code = this.value } } #toolbarClick(ev) { var elem = ev.target var act this.restoreSelection() if (elem === ev.currentTarget) { return } if (this.readOnly || this.disabled) { return } while (elem.tagName !== 'SPAN') { elem = elem.parentNode } act = elem.dataset.act // this.#hideLayers() Addon[act].call(this, elem) } #chnageFontSize(ev) { if (ev.target === ev.currentTarget) { return } this.$refs.font.classList.remove('fadein') this.$refs.editor.focus() this.restoreSelection() this.exec(ACTTION.font, ev.target.dataset.size) this.saveSelection() } #chnageColor(ev) { if (ev.target === ev.currentTarget) { return } this.$refs.color.classList.remove('fadein') this.$refs.editor.focus() this.restoreSelection() this.exec(ACTTION.color, ev.target.dataset.value) this.saveSelection() } #insertLink(ev) { let value = this.$refs.linkinput.value.trim() if (value) { this.$refs.link.classList.remove('fadein') this.$refs.editor.focus() this.restoreSelection() this.exec(ACTTION.link, value) this.saveSelection() this.$refs.linkinput.value = '' } } #insertTable(ev) { let th = ` `.repeat(this.#gridx + 1) let td = ` `.repeat(this.#gridx + 1) this.exec( 'insertHtml', `
${th}${`${td}`.repeat(this.#gridy + 1)}

` ) this.$refs.table.classList.remove('fadein') } #tableSelect(ev) { let grids = Array.from(this.$refs.table.children) if (ev.type === 'mousemove') { if (ev.target === ev.currentTarget) { return } let idx = +ev.target.dataset.idx let x = getX(idx) let y = getY(idx) // 避免每次遍历完所有的节点 let max = Math.max(getIndex(this.#gridx, this.#gridy), idx) + 1 if (x === this.#gridx && y === this.#gridy) { return } this.#gridx = x this.#gridy = y for (let i = 0; i < max; i++) { let _x = getX(i) let _y = getY(i) grids[i].classList.toggle('active', _x <= x && _y <= y) } } else { grids.forEach(it => it.classList.remove('active')) } } // 保存选中 saveSelection() { var gs = this.root.getSelection() if (gs.getRangeAt && gs.rangeCount) { this.#select = gs.getRangeAt(0) } } // 清除选中并重置选中 restoreSelection() { var gs = this.root.getSelection() if (this.#select) { try { gs.removeAllRanges() } catch (err) {} gs.addRange(this.#select) } } /** * 往文本框中插入内容 * @param {String} val [要插入的文本] * @param {Boolean} isSelect [插入之后是否要选中文本] * @param {Boolean} offset [选中文本时的偏移量] */ insert(val = '', isSelect = false, offset = 0) { let $el = this.$refs.editor let start = $el.selectionStart let end = $el.selectionEnd let scrollTop = $el.scrollTop if (start || start === 0) { $el.value = $el.value.slice(0, start) + val + $el.value.slice(end) this.select( (isSelect ? start : start + val.length) + offset, start + val.length - offset ) $el.scrollTop = scrollTop $el.focus() } else { $el.value += val $el.focus() } this.#updatePreview() this.$emit('input') } /** * [selection 获取选中的文本] * @param {[type]} forceHoleLine [是否强制光标所在的整行文本] */ selection(forceHoleLine = false) { let $el = this.$refs.editor let start = $el.selectionStart let end = $el.selectionEnd if (end) { //强制选择整行 if (forceHoleLine) { start = $el.value.slice(0, start).lastIndexOf('\n') let tmpEnd = $el.value.slice(end).indexOf('\n') tmpEnd = tmpEnd < 0 ? $el.value.slice(end).length : tmpEnd start += 1 // 把\n加上 end += tmpEnd $el.selectionStart = start $el.selectionEnd = end } } else { //强制选择整行 if (forceHoleLine) { end = $el.value.indexOf('\n') end = end < 0 ? $el.value.length : end $el.selectionEnd = end } } $el.focus() return $el.value.slice(start, end) } select(start = 0, end = 0) { let $el = this.$refs.editor $el.selectionStart = start $el.selectionEnd = end } // 设置光标 cursor(pos) { this.select(pos, pos) } #cursorMove(step) { let $el = this.$refs.editor let pos = (step < 0 ? $el.selectionStart : $el.selectionEnd) || 0 pos += step if (step === 0) { return } if (pos < 0) { pos = 0 } this.cursor(pos) } // 获取光标处的字符 #getCursorText(n) { let $el = this.$refs.editor let start = $el.selectionStart let pos = $el.selectionEnd if (n < 0) { pos = start - 1 } return this.value[pos] || '' } #handleKeydown(ev) { let $el = this.$refs.editor let wrapTxt = this.selection() || '' let selected = wrapTxt.length > 0 let newTxt = '' if (this.readOnly || this.disabled) { return } switch (ev.keyCode) { //tab键改为插入2个空格,阻止默认事件,防止焦点失去 case 9: ev.preventDefault() if (ev.shiftKey && !selected) { let pos = $el.selectionStart let line = this.selection(true) newTxt = line.replace(/^\s{2}/, '') // 防止无法往左取消缩进时, 选中整行 if (line === newTxt) { this.cursor(pos) break } pos -= 2 this.insert(newTxt, selected) this.cursor(pos) } else { newTxt = wrapTxt .split('\n') .map(function (it) { return ev.shiftKey ? it.replace(/^\s{2}/, '') : ' ' + it }) .join('\n') if (newTxt === wrapTxt) { break } this.insert(newTxt, selected) } break // 退格键, 遇到成对的符号时,同时删除 case 8: if (!selected) { let pos = $el.selectionStart let prev = this.#getCursorText(-1) let next = this.#getCursorText(1) if ( (prev === next && (prev === '"' || prev === "'")) || (prev === next && prev === '`') || (prev === '[' && next === ']') || (prev === '{' && next === '}') || (prev === '(' && next === ')') ) { this.select(pos - 1, pos + 1) } } break // ( case 57: if (ev.shiftKey) { ev.preventDefault() this.insert('(' + wrapTxt + ')', selected, (selected && 1) || 0) this.#cursorMove(selected ? 0 : -1) } break // ) case 48: if (ev.shiftKey) { let prev = this.#getCursorText(-1) let next = this.#getCursorText(1) if (prev === '(' && next === ')') { ev.preventDefault() this.#cursorMove(1) } } break case 219: // [ & { ev.preventDefault() this.insert( ev.shiftKey ? `{${wrapTxt}}` : `[${wrapTxt}]`, selected, selected ^ 0 ) this.#cursorMove(selected ? 0 : -1) break // } & ] case 221: { let prev = this.#getCursorText(-1) let next = this.#getCursorText(1) if (ev.shiftKey) { if (prev === '{' && next === '}') { ev.preventDefault() this.#cursorMove(1) } } else { if (prev === '[' && next === ']') { ev.preventDefault() this.#cursorMove(1) } } break } // ` case 192: // ' & " case 222: if (ev.shiftKey && ev.keyCode === 192) { break } else { let val = ev.keyCode === 192 ? '`' : ev.shiftKey ? '"' : "'" let prev = this.#getCursorText(-1) let next = this.#getCursorText(1) if (prev === '' && next === val) { break } ev.preventDefault() if (selected) { this.insert(val + wrapTxt + val, true, 1) } else { if (prev === next && prev === val) { this.#cursorMove(1) } else { this.insert(val + wrapTxt + val) this.#cursorMove(-1) } } break } } } mounted() { Addon.preview.call(this, this) } unmounted() {} render() { // console.log(this.#toolbar, this.value) return html`
${renderToolbar(this.#toolbar)}
` } } MEditor.reg('meditor')