From 98d66014adddfe435e070c05d1d5a0890723c9ac Mon Sep 17 00:00:00 2001 From: yutent Date: Tue, 19 Sep 2023 18:50:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0meditor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/meditor/addon.js | 141 ++++++++ src/meditor/helper.js | 222 +++++++++++++ src/meditor/index.js | 755 ++++++++++++++++++++++++++++++++++++++++++ src/meditor/svg.js | 42 +++ 4 files changed, 1160 insertions(+) create mode 100644 src/meditor/addon.js create mode 100644 src/meditor/helper.js create mode 100644 src/meditor/index.js create mode 100644 src/meditor/svg.js diff --git a/src/meditor/addon.js b/src/meditor/addon.js new file mode 100644 index 0000000..b9b89d7 --- /dev/null +++ b/src/meditor/addon.js @@ -0,0 +1,141 @@ +/** + * 基础拓展 + * @author yutent + * @date 2020/10/14 17:52:44 + */ + +import { offset } from 'wkit' + +var placeholder = '在此输入文本' + +function trim(str, sign) { + return str.replace(new RegExp('^' + sign + '|' + sign + '$', 'g'), '') +} + +function docScroll(k = 'X') { + return window[`page${k.toUpperCase()}Offset`] +} + +// 通用的弹层触发 +function showDialog(dialog, elem) { + var { left, top } = offset(elem) + left -= docScroll('X') + top += 29 - docScroll('Y') + left += 'px' + top += 'px' + dialog.moveTo({ top, left }) + dialog.show() + return Promise.resolve(dialog) +} + +export default { + header(elem) { + showDialog(this.__HEADER_ADDON__, elem) + }, + + h(level) { + var wrap = this.selection(true) || placeholder + wrap = wrap.replace(/^(#+ )?/, '#'.repeat(level) + ' ') + this.insert(wrap, true) + }, + + quote(elem) { + var wrap = this.selection(true) || placeholder + wrap = wrap.replace(/^(>+ )?/, '> ') + + this.insert(wrap, true) + }, + + bold(elem) { + var wrap = this.selection() || placeholder + var unwrap = trim(wrap, '\\*\\*') + wrap = wrap === unwrap ? `**${wrap}**` : unwrap + this.insert(wrap, true) + }, + + italic(elem) { + var wrap = this.selection() || placeholder + var unwrap = trim(wrap, '_') + wrap = wrap === unwrap ? `_${wrap}_` : unwrap + this.insert(wrap, true) + }, + + through(elem) { + var wrap = this.selection() || placeholder + var unwrap = trim(wrap, '~~') + wrap = wrap === unwrap ? `~~${wrap}~~` : unwrap + this.insert(wrap, true) + }, + + list(elem) { + var wrap = this.selection(true) || placeholder + + wrap = wrap.replace(/^([+\-*] )?/, '+ ') + this.insert(wrap, true) + }, + + order(elem) { + var wrap = this.selection(true) || placeholder + + wrap = wrap.replace(/^(\d+\. )?/, '1. ') + this.insert(wrap, true) + }, + + line(elem) { + this.insert('\n\n---\n\n', false) + }, + + code(elem) { + var wrap = this.selection() || placeholder + var unwrap = trim(wrap, '`') + wrap = wrap === unwrap ? `\`${wrap}\`` : unwrap + this.insert(wrap, true) + }, + + codeblock(elem) { + this.insert('\n```language\n\n```\n') + }, + + table(elem) { + showDialog(this.__TABLE_ADDON__, elem) + }, + + link(elem) { + showDialog(this.__LINK_ADDON__, elem).then(dialog => { + var wrap = this.selection() || placeholder + dialog.__txt__.value = wrap + }) + }, + + image(elem) { + var $file = this.__ATTACH_ADDON__.querySelector('input') + + this._attach = 'image' + $file.setAttribute('accept', 'image/*') + + showDialog(this.__ATTACH_ADDON__, elem) + }, + + attach(elem) { + var $file = this.__ATTACH_ADDON__.querySelector('input') + this._attach = 'file' + $file.removeAttribute('accept') + showDialog(this.__ATTACH_ADDON__, elem) + }, + + fullscreen(elem) { + // + this.props.fullscreen = !this.props.fullscreen + if (this.props.fullscreen) { + this.setAttribute('fullscreen', '') + } else { + this.removeAttribute('fullscreen') + } + elem.classList.toggle('active', this.props.fullscreen) + }, + preview(elem) { + this.state.preview = !this.state.preview + this.__VIEW__.classList.toggle('active', this.state.preview) + elem.classList.toggle('active', this.state.preview) + } +} diff --git a/src/meditor/helper.js b/src/meditor/helper.js new file mode 100644 index 0000000..12446dd --- /dev/null +++ b/src/meditor/helper.js @@ -0,0 +1,222 @@ +/** + * 一些公共的东西 + * @author yutent + * @date 2020/10/12 18:23:23 + */ + +import ICONS from './svg' + +const ELEMS = { + a: function (str, attr, inner) { + let href = attr.match(attrExp('href')) + let title = attr.match(attrExp('title')) + let tar = attr.match(attrExp('target')) + let attrs = '' + + href = (href && href[1]) || null + title = (title && title[1]) || null + tar = (tar && tar[1]) || '_self' + + if (!href) { + return inner || href + } + + href = href.replace('viod(0)', '') + attrs = `target=${tar}` + attrs += title ? `;title=${title}` : '' + + return `[${inner || href}](${href} "${attrs}")` + }, + em: function (str, attr, inner) { + return (inner && '_' + inner + '_') || '' + }, + strong: function (str, attr, inner) { + return (inner && '**' + inner + '**') || '' + }, + pre: function (str, attr, inner) { + inner = inner.replace(/<[/]?code>/g, '') + return '\n\n```\n' + inner + '\n```\n' + }, + code: function (str, attr, inner) { + return (inner && '`' + inner + '`') || '' + }, + blockquote: function (str, attr, inner) { + return '> ' + inner.trim() + }, + img: function (str, attr, inner) { + var src = attr.match(attrExp('src')), + alt = attr.match(attrExp('alt')) + + src = (src && src[1]) || '' + alt = (alt && alt[1]) || '' + + return '![' + alt + '](' + src + ')' + }, + p: function (str, attr, inner) { + return inner ? '\n' + inner : '' + }, + br: '\n', + 'h([1-6])': function (str, level, attr, inner) { + let h = '#'.repeat(level) + return '\n' + h + ' ' + inner + '\n' + }, + hr: '\n\n---\n\n' +} + +const DEFAULT_TOOLS = [ + 'header', + 'quote', + 'bold', + 'italic', + 'through', + 'list', + 'order', + 'line', + 'code', + 'codeblock', + 'table', + 'link', + 'image', + 'attach', + 'fullscreen', + 'preview' +] + +export const TOOL_TITLE = { + header: '插入标题', + h1: '一级标题', + h2: '二级标题', + h3: '三级标题', + h4: '四级标题', + h5: '五级标题', + h6: '六级标题', + quote: '引用文本', + bold: '粗体', + italic: '斜体', + through: '横线', + list: '无序列表', + order: '有序列表', + line: '分割线', + code: '行内代码', + codeblock: '插入代码块', + table: '插入表格', + link: '插入连接', + image: '上传图片', + attach: '上传附件', + fullscreen: '全屏编辑', + preview: '预览' +} + +const LI_EXP = /<(ul|ol)>(?:(?!/gi + +// html标签的属性正则 +function attrExp(field, flag = 'i') { + return new RegExp(field + '\\s?=\\s?["\']?([^"\']*)["\']?', flag) +} + +// 生成html标签的正则 +function tagExp(tag, open) { + var exp = '' + if (['br', 'hr', 'img'].indexOf(tag) > -1) { + exp = '<' + tag + '([^>]*?)\\/?>' + } else { + exp = '<' + tag + '([^>]*?)>([\\s\\S]*?)<\\/' + tag + '>' + } + return new RegExp(exp, 'gi') +} + +/** + * 渲染工具栏图标 + */ +export function renderToolbar(list, tag = 'span', dict = {}, showText = false) { + return (list || DEFAULT_TOOLS) + .map(it => { + var title = showText ? '' : `title="${dict[it] || ''}"` + var text = showText ? dict[it] || '' : '' + + return `<${tag} data-act="${it}" ${title}>${text}` + }) + .join('') +} + +/** + * html转成md + */ +export function html2md(str) { + try { + str = decodeURIComponent(str) + } catch (err) {} + + str = str + .replace(/\t/g, ' ') + .replace(/]*>/, '') + .replace(attrExp('class', 'g'), '') + .replace(attrExp('style', 'g'), '') + .replace(/<(?!a |img )(\w+) [^>]*>/g, '<$1>') + .replace(/]*>.*?<\/svg>/g, '{invalid image}') + + // log(str) + for (let i in ELEMS) { + let cb = ELEMS[i] + let exp = tagExp(i) + + if (i === 'blockquote') { + while (str.match(exp)) { + str = str.replace(exp, cb) + } + } else { + str = str.replace(exp, cb) + } + + // 对另外3种同类标签做一次处理 + if (i === 'p') { + exp = tagExp('div') + str = str.replace(exp, cb) + } + if (i === 'em') { + exp = tagExp('i') + str = str.replace(exp, cb) + } + if (i === 'strong') { + exp = tagExp('b') + str = str.replace(exp, cb) + } + } + + while (str.match(LI_EXP)) { + str = str.replace(LI_EXP, function (match) { + match = match.replace( + /<(ul|ol)>([\s\S]*?)<\/\1>/gi, + function (m, t, inner) { + let li = inner.split('') + li.pop() + + for (let i = 0, len = li.length; i < len; i++) { + let pre = t === 'ol' ? i + 1 + '. ' : '* ' + li[i] = + pre + + li[i] + .replace(/\s*
  • ([\s\S]*)/i, function (m, n) { + n = n.trim().replace(/\n/g, '\n ') + return n + }) + .replace(/<[\/]?[\w]*[^>]*>/g, '') + } + return li.join('\n') + } + ) + return '\n' + match.trim() + }) + } + str = str + + .replace(/<[\/]?[\w]*[^>]*>/g, '') + .replace(/```([\w\W]*)```/g, function (str, inner) { + inner = inner + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + return '```' + inner + '```' + }) + return str +} diff --git a/src/meditor/index.js b/src/meditor/index.js new file mode 100644 index 0000000..d4aec4b --- /dev/null +++ b/src/meditor/index.js @@ -0,0 +1,755 @@ +/** + * {} + * @author yutent + * @date 2023/09/14 16:49:15 + */ + +import { + css, + raw, + html, + Component, + bind, + unbind, + nextTick, + styleMap, + classMap, + outsideClick, + clearOutsideClick +} from 'wkit' +import ICONS from './svg.js' +import '../form/input.js' +import '../form/button.js' + +const ACTTION = { + bold: 'bold', + italic: 'italic', + under: 'underline', + delete: 'strikeThrough', + left: 'justifyLeft', + center: 'justifyCenter', + right: 'justifyRight', + image: 'insertImage', + font: 'fontSize', + color: 'foreColor', + link: 'createLink', + ordered: 'insertOrderedList', + unordered: 'insertUnorderedList' +} + +const DEFAULT_TOOLS = [ + 'font', + 'color', + 'bold', + 'italic', + 'under', + 'delete', + 'ordered', + 'unordered', + 'table', + 'left', + 'center', + 'right', + 'link', + 'image', + 'fullscreen' +] + +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 props = { + toolbar: { + type: String, + default: '', + attribute: false, + observer(v) { + if (v === null) { + this.#toolbar = [...DEFAULT_TOOLS] + } else if (v) { + this.#toolbar = v.split(',').map(it => it.trim()) + } + } + }, + value: 'str!', + readonly: { + type: Boolean, + observer(v) { + this.#updateStat() + } + }, + disabled: { + type: Boolean, + observer(v) { + this.#updateStat() + } + } + } + + 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; + } + } + } + ` + ] + + #toolbar = [] + #value = '' + #cache = { bar: 0, y: 0 } + #gridx = 0 + #gridy = 0 + + #select = null + + __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' + } + + #updateStat() { + if (this.$refs.editor) { + if (this.readOnly || this.disabled) { + this.$refs.editor.removeAttribute('contenteditable') + } else { + this.$refs.editor.setAttribute('contenteditable', '') + } + } else { + nextTick(_ => this.#updateStat()) + } + } + + #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') + } + + // 处理图片 + #handleImage(ev, file) { + 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.restoreSelection() + this.exec(ACTTION.image, link) + this.saveSelection() + + // 修正插入的图片,宽度不得超出容器 + this.$refs.editor.querySelectorAll('img').forEach(_ => { + _.style.maxWidth = '100%' + }) + } + } + }) + } + + #handlePaste(ev) { + let html = ev.clipboardData.getData('text/html') + let txt = ev.clipboardData.getData('text/plain') + let items = ev.clipboardData.items + + // 先文件判断, 避免右键单击复制图片时, 当成html处理 + if (items && items.length) { + let blob = null + for (let it of items) { + if (it.type.indexOf('image') > -1) { + blob = it.getAsFile() + } + } + + if (blob) { + return this.#handleImage(null, blob) + } + } + + if (html) { + html = html + .replace(/\t/g, ' ') + .replace(/<\/?(meta|link|script)[^>]*?>/g, '') + .replace(//g, '') + .replace( + /]*? href\s?=\s?["']?([^"']*)["']?[^>]*?>/g, + '' + ) + .replace( + /]*? src\s?=\s?["']?([^"']*)["']?[^>]*?>/g, + '' + ) + .replace(/<(?!a|img)([\w\-]+)[^>]*>/g, '<$1>') + .replace(/]*?>[\w\W]*?<\/xml>/g, '') + .replace(/