import Quill from 'quill' import Delta from 'quill-delta' import 'quill/dist/quill.snow.css' import { walk, getTextFromHtml, isUndef, checkSmmFormatData, removeHtmlNodeByClass, formatGetNodeGeneralization, nodeRichTextToTextWithWrap } from '../utils' import { CONSTANTS } from '../constants/constant' import MindMapNode from '../core/render/node/MindMapNode' import { Scope } from 'parchment' let extended = false // 扩展quill的字体列表 let fontFamilyList = [ '宋体, SimSun, Songti SC', '微软雅黑, Microsoft YaHei', '楷体, 楷体_GB2312, SimKai, STKaiti', '黑体, SimHei, Heiti SC', '隶书, SimLi', 'andale mono', 'arial, helvetica, sans-serif', 'arial black, avant garde', 'comic sans ms', 'impact, chicago', 'times new roman', 'sans-serif', 'serif' ] // 扩展quill的字号列表 let fontSizeList = new Array(100).fill(0).map((_, index) => { return index + 'px' }) // 富文本编辑插件 class RichText { constructor({ mindMap, pluginOpt }) { this.mindMap = mindMap this.pluginOpt = pluginOpt this.textEditNode = null this.showTextEdit = false this.quill = null this.range = null this.lastRange = null this.pasteUseRange = null this.node = null this.isInserting = false this.styleEl = null this.cacheEditingText = '' this.lostStyle = false this.isCompositing = false this.textNodePaddingX = 6 this.textNodePaddingY = 4 this.initOpt() this.extendQuill() this.appendCss() this.bindEvent() // 处理数据,转成富文本格式 if (this.mindMap.opt.data) { this.mindMap.opt.data = this.handleSetData(this.mindMap.opt.data) } } // 绑定事件 bindEvent() { this.onCompositionStart = this.onCompositionStart.bind(this) this.onCompositionUpdate = this.onCompositionUpdate.bind(this) this.onCompositionEnd = this.onCompositionEnd.bind(this) window.addEventListener('compositionstart', this.onCompositionStart) window.addEventListener('compositionupdate', this.onCompositionUpdate) window.addEventListener('compositionend', this.onCompositionEnd) } // 解绑事件 unbindEvent() { window.removeEventListener('compositionstart', this.onCompositionStart) window.removeEventListener('compositionupdate', this.onCompositionUpdate) window.removeEventListener('compositionend', this.onCompositionEnd) } // 插入样式 appendCss() { this.mindMap.appendCss( 'richText', ` .smm-richtext-node-wrap { word-break: break-all; } .smm-richtext-node-wrap p { font-family: auto; } ` ) let cssText = ` .ql-editor { overflow: hidden; padding: 0; height: auto; line-height: normal; -webkit-user-select: text; } .ql-container { height: auto; font-size: inherit; } .ql-container.ql-snow { border: none; } .smm-richtext-node-edit-wrap p { font-family: auto; } ` this.styleEl = document.createElement('style') this.styleEl.type = 'text/css' this.styleEl.innerHTML = cssText document.head.appendChild(this.styleEl) } // 处理选项参数 initOpt() { if ( this.pluginOpt.fontFamilyList && Array.isArray(this.pluginOpt.fontFamilyList) ) { fontFamilyList = this.pluginOpt.fontFamilyList } if ( this.pluginOpt.fontSizeList && Array.isArray(this.pluginOpt.fontSizeList) ) { fontSizeList = this.pluginOpt.fontSizeList } } // 扩展quill编辑器 extendQuill() { if (extended) { return } extended = true this.extendFont([]) // 扩展quill的字号列表 const SizeAttributor = Quill.import('attributors/class/size') SizeAttributor.whitelist = fontSizeList Quill.register(SizeAttributor, true) const SizeStyle = Quill.import('attributors/style/size') SizeStyle.whitelist = fontSizeList Quill.register(SizeStyle, true) } // 扩展字体列表 extendFont(list = [], cover = false) { fontFamilyList = cover ? [...list] : [...fontFamilyList, ...list] // 扩展quill的字体列表 const FontAttributor = Quill.import('attributors/class/font') FontAttributor.whitelist = fontFamilyList Quill.register(FontAttributor, true) const FontStyle = Quill.import('attributors/style/font') FontStyle.whitelist = fontFamilyList Quill.register(FontStyle, true) } // 显示文本编辑控件 showEditText({ node, rect, isInserting, isFromKeyDown, isFromScale }) { if (this.showTextEdit) { return } let { richTextEditFakeInPlace, customInnerElsAppendTo, nodeTextEditZIndex, textAutoWrapWidth, selectTextOnEnterEditText, transformRichTextOnEnterEdit, openRealtimeRenderOnNodeTextEdit } = this.mindMap.opt textAutoWrapWidth = node.hasCustomWidth() ? node.customTextWidth : textAutoWrapWidth this.node = node this.isInserting = isInserting if (!rect) rect = node._textData.node.node.getBoundingClientRect() if (!isFromScale) { this.mindMap.emit('before_show_text_edit') } this.mindMap.renderer.textEdit.registerTmpShortcut() // 原始宽高 let g = node._textData.node let originWidth = g.attr('data-width') let originHeight = g.attr('data-height') // 缩放值 const scaleX = Math.ceil(rect.width) / originWidth const scaleY = Math.ceil(rect.height) / originHeight // 内边距 let paddingX = this.textNodePaddingX let paddingY = this.textNodePaddingY if (richTextEditFakeInPlace) { let paddingValue = node.getPaddingVale() paddingX = paddingValue.paddingX paddingY = paddingValue.paddingY } if (!this.textEditNode) { this.textEditNode = document.createElement('div') this.textEditNode.classList.add('smm-richtext-node-edit-wrap') this.textEditNode.style.cssText = ` position:fixed; box-sizing: border-box; ${ openRealtimeRenderOnNodeTextEdit ? '' : 'box-shadow: 0 0 20px rgba(0,0,0,.5);' } outline: none; word-break: break-all; padding: ${paddingY}px ${paddingX}px; ` this.textEditNode.addEventListener('click', e => { e.stopPropagation() }) this.textEditNode.addEventListener('mousedown', e => { e.stopPropagation() }) this.textEditNode.addEventListener('keydown', e => { if (this.mindMap.renderer.textEdit.checkIsAutoEnterTextEditKey(e)) { e.stopPropagation() } }) const targetNode = customInnerElsAppendTo || document.body targetNode.appendChild(this.textEditNode) } this.textEditNode.style.marginLeft = `-${paddingX * scaleX}px` this.textEditNode.style.marginTop = `-${paddingY * scaleY}px` this.textEditNode.style.zIndex = nodeTextEditZIndex if (!openRealtimeRenderOnNodeTextEdit) { this.textEditNode.style.background = this.mindMap.renderer.textEdit.getBackground(node) } this.textEditNode.style.minWidth = originWidth + paddingX * 2 + 'px' this.textEditNode.style.minHeight = originHeight + 'px' this.textEditNode.style.left = rect.left + 'px' this.textEditNode.style.top = rect.top + 'px' this.textEditNode.style.display = 'block' this.textEditNode.style.maxWidth = textAutoWrapWidth + paddingX * 2 + 'px' this.textEditNode.style.transform = `scale(${scaleX}, ${scaleY})` this.textEditNode.style.transformOrigin = 'left top' if (richTextEditFakeInPlace) { this.textEditNode.style.borderRadius = (node.style.merge('borderRadius') || 5) + 'px' if (node.style.merge('shape') == 'roundedRectangle') { this.textEditNode.style.borderRadius = (node.height || 50) + 'px' } } // 节点文本内容 let nodeText = node.getData('text') if (typeof transformRichTextOnEnterEdit === 'function') { nodeText = transformRichTextOnEnterEdit(nodeText) } // 是否是空文本 const isEmptyText = isUndef(nodeText) // 是否是非空的非富文本 const noneEmptyNoneRichText = !node.getData('richText') && !isEmptyText // 如果是空文本,那么设置为丢失样式状态,否则输入不会带上样式 if (isEmptyText) { this.lostStyle = true } if (noneEmptyNoneRichText) { // 还不是富文本 let text = String(nodeText).split(/\n/gim).join('
') let html = `

${text}

` this.textEditNode.innerHTML = this.cacheEditingText || html } else { // 已经是富文本 this.textEditNode.innerHTML = this.cacheEditingText || nodeText } this.initQuillEditor() document.querySelector('.ql-editor').style.minHeight = originHeight + 'px' this.showTextEdit = true // 如果是刚创建的节点,那么默认全选,否则普通激活不全选,除非selectTextOnEnterEditText配置为true // 在selectTextOnEnterEditText时,如果是在keydown事件进入的节点编辑,也不需要全选 this.focus( isInserting || (selectTextOnEnterEditText && !isFromKeyDown) ? 0 : null ) if (noneEmptyNoneRichText) { // 如果是非富文本的情况,需要手动应用文本样式 this.setTextStyleIfNotRichText(node) } this.cacheEditingText = '' } // 当openRealtimeRenderOnNodeTextEdit配置更新后需要更新编辑框样式 onOpenRealtimeRenderOnNodeTextEditConfigUpdate( openRealtimeRenderOnNodeTextEdit ) { if (!this.textEditNode) return this.textEditNode.style.background = openRealtimeRenderOnNodeTextEdit ? 'transparent' : this.node ? this.mindMap.renderer.textEdit.getBackground(this.node) : '' this.textEditNode.style.boxShadow = openRealtimeRenderOnNodeTextEdit ? 'none' : '0 0 20px rgba(0,0,0,.5)' } // 更新文本编辑框的大小和位置 updateTextEditNode() { if (!this.node) return const g = this.node._textData.node const rect = g.node.getBoundingClientRect() const originWidth = g.attr('data-width') const originHeight = g.attr('data-height') this.textEditNode.style.minWidth = originWidth + this.textNodePaddingX * 2 + 'px' this.textEditNode.style.minHeight = originHeight + 'px' this.textEditNode.style.left = rect.left + 'px' this.textEditNode.style.top = rect.top + 'px' } // 删除文本编辑框元素 removeTextEditEl() { if (!this.textEditNode) return const targetNode = this.mindMap.opt.customInnerElsAppendTo || document.body targetNode.removeChild(this.textEditNode) } // 如果是非富文本的情况,需要手动应用文本样式 setTextStyleIfNotRichText(node) { let style = { font: node.style.merge('fontFamily'), color: node.style.merge('color'), italic: node.style.merge('fontStyle') === 'italic', bold: node.style.merge('fontWeight') === 'bold', size: node.style.merge('fontSize') + 'px', underline: node.style.merge('textDecoration') === 'underline', strike: node.style.merge('textDecoration') === 'line-through' } this.pureFormatAllText(style) } // 获取当前正在编辑的内容 getEditText() { let html = this.quill.container.firstChild.innerHTML // 去除ql-cursor节点 // https://github.com/wanglin2/mind-map/commit/138cc4b3e824671143f0bf70e5c46796f48520d0 // https://github.com/wanglin2/mind-map/commit/0760500cebe8ec4e8ad84ab63f877b8b2a193aa1 // html = removeHtmlNodeByClass(html, '.ql-cursor') // 去除最后的空行 return html.replace(/


<\/p>$/, '') } // 给html字符串中的节点样式按样式名首字母排序 sortHtmlNodeStyles(html) { return html.replace(/(<[^<>]+\s+style=")([^"]+)("\s*>)/g, (_, a, b, c) => { let arr = b.match(/[^:]+:[^:]+;/g) || [] arr = arr.map(item => { return item.trim() }) arr.sort() return a + arr.join('') + c }) } // 隐藏文本编辑控件,即完成编辑 hideEditText(nodes) { if (!this.showTextEdit) { return } const { beforeHideRichTextEdit } = this.mindMap.opt if (typeof beforeHideRichTextEdit === 'function') { beforeHideRichTextEdit(this) } let html = this.getEditText() html = this.sortHtmlNodeStyles(html) const list = nodes && nodes.length > 0 ? nodes : [this.node] const node = this.node this.textEditNode.style.display = 'none' this.showTextEdit = false this.mindMap.emit('rich_text_selection_change', false) this.node = null this.isInserting = false list.forEach(node => { this.mindMap.execCommand('SET_NODE_TEXT', node, html, true) // if (node.isGeneralization) { // 概要节点 // node.generalizationBelongNode.updateGeneralization() // } this.mindMap.render() }) this.mindMap.emit('hide_text_edit', this.textEditNode, list, node) } // 初始化Quill富文本编辑器 initQuillEditor() { this.quill = new Quill(this.textEditNode, { modules: { toolbar: false, keyboard: { bindings: { enter: { key: 'Enter', handler: function () { // 覆盖默认的回车键,禁止换行 } }, shiftEnter: { key: 'Enter', shiftKey: true, handler: function (range, context) { // 覆盖默认的换行,默认情况下新行的样式会丢失 const lineFormats = Object.keys(context.format).reduce( (formats, format) => { if ( this.quill.scroll.query(format, Scope.BLOCK) && !Array.isArray(context.format[format]) ) { formats[format] = context.format[format] } return formats }, {} ) const delta = new Delta() .retain(range.index) .delete(range.length) .insert('\n', lineFormats) this.quill.updateContents(delta, Quill.sources.USER) this.quill.setSelection(range.index + 1, Quill.sources.SILENT) this.quill.focus() Object.keys(context.format).forEach(name => { if (lineFormats[name] != null) return if (Array.isArray(context.format[name])) return if (name === 'code' || name === 'link') return this.quill.format( name, context.format[name], Quill.sources.USER ) }) } }, tab: { key: 9, handler: function () { // 覆盖默认的tab键 } } } } }, theme: 'snow' }) // 拦截复制事件,即Ctrl + c,去除多余的空行 this.quill.root.addEventListener('copy', event => { event.preventDefault() const sel = window.getSelection() const originStr = sel.toString() try { const range = sel.getRangeAt(0) const div = document.createElement('div') div.appendChild(range.cloneContents()) const text = nodeRichTextToTextWithWrap(div.innerHTML) event.clipboardData.setData('text/plain', text) } catch (e) { event.clipboardData.setData('text/plain', originStr) } }) this.quill.on('selection-change', range => { // 刚创建的节点全选不需要显示操作条 if (this.isInserting) return this.lastRange = this.range this.range = null if (range) { this.pasteUseRange = range let bounds = this.quill.getBounds(range.index, range.length) let rect = this.textEditNode.getBoundingClientRect() let rectInfo = { left: bounds.left + rect.left, top: bounds.top + rect.top, right: bounds.right + rect.left, bottom: bounds.bottom + rect.top, width: bounds.width } let formatInfo = this.quill.getFormat(range.index, range.length) let hasRange = false if (range.length == 0) { hasRange = false } else { this.range = range hasRange = true } this.mindMap.emit( 'rich_text_selection_change', hasRange, rectInfo, formatInfo ) } else { this.mindMap.emit('rich_text_selection_change', false, null, null) } }) this.quill.on('text-change', () => { let contents = this.quill.getContents() let len = contents.ops.length // 如果编辑过程中删除所有字符,那么会丢失主题的样式 if (len <= 0 || (len === 1 && contents.ops[0].insert === '\n')) { this.lostStyle = true // 需要删除节点的样式数据 this.syncFormatToNodeConfig(null, true) } else if (this.lostStyle && !this.isCompositing) { // 如果处于样式丢失状态,那么需要进行格式化加回样式 this.setTextStyleIfNotRichText(this.node) this.lostStyle = false } this.mindMap.emit('node_text_edit_change', { node: this.node, text: this.getEditText(), richText: true }) }) // 拦截粘贴,只允许粘贴纯文本 // this.quill.clipboard.addMatcher(Node.TEXT_NODE, node => { // let style = this.getPasteTextStyle() // return new Delta().insert(this.formatPasteText(node.data), style) // }) // 剪贴板里只要存在文本就会走这里,所以当剪贴板里是纯文本,或文本+图片都可以监听到和拦截,但是只有纯图片时不会走这里,所以无法拦截 this.quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => { let ops = [] let style = this.getPasteTextStyle() delta.ops.forEach(op => { // 过滤出文本内容,过滤掉换行 if (op.insert && typeof op.insert === 'string') { ops.push({ attributes: { ...style }, insert: this.formatPasteText(op.insert) }) } }) delta.ops = ops return delta }) // 拦截图片的粘贴,当剪贴板里是纯图片,或文本+图片都可以拦截到,但是带来的问题是文本+图片时里面的文本也无法粘贴 this.quill.root.addEventListener( 'paste', e => { if ( e.clipboardData && e.clipboardData.files && e.clipboardData.files.length ) { e.preventDefault() } }, true ) } // 获取粘贴的文本的样式 getPasteTextStyle() { // 粘贴的数据使用当前光标位置处的文本样式 if (this.pasteUseRange) { return this.quill.getFormat( this.pasteUseRange.index, this.pasteUseRange.length ) } return {} } // 处理粘贴的文本内容 formatPasteText(text) { const { isSmm, data } = checkSmmFormatData(text) if (isSmm && data[0] && data[0].data) { // 只取第一个节点的纯文本 return getTextFromHtml(data[0].data.text) } else { return text } } // 正则输入中文 onCompositionStart() { if (!this.showTextEdit) { return } this.isCompositing = true } // 中文输入中 onCompositionUpdate() { if (!this.showTextEdit || !this.node) return this.mindMap.emit('node_text_edit_change', { node: this.node, text: this.getEditText(), richText: true }) } // 中文输入结束 onCompositionEnd() { if (!this.showTextEdit) { return } this.isCompositing = false if (!this.lostStyle) { return } this.setTextStyleIfNotRichText(this.node) } // 选中全部 selectAll() { this.quill.setSelection(0, this.quill.getLength()) } // 聚焦 focus(start) { let len = this.quill.getLength() this.quill.setSelection(typeof start === 'number' ? start : len, len) } // 格式化当前选中的文本 formatText(config = {}, clear = false, pure = false) { if (!this.range && !this.lastRange) return if (!pure) this.syncFormatToNodeConfig(config, clear) let rangeLost = !this.range let range = rangeLost ? this.lastRange : this.range clear ? this.quill.removeFormat(range.index, range.length) : this.quill.formatText(range.index, range.length, config) if (rangeLost) { this.quill.setSelection(this.lastRange.index, this.lastRange.length) } } // 清除当前选中文本的样式 removeFormat() { // 先移除全部样式 this.formatText({}, true) // 再将样式恢复为当前主题改节点的默认样式 const style = {} if (this.node) { ;[ 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textDecoration', 'color' ].forEach(key => { style[key] = this.node.style.merge(key) }) } const config = this.normalStyleToRichTextStyle(style) this.formatText(config, false, true) } // 格式化指定范围的文本 formatRangeText(range, config = {}) { if (!range) return this.syncFormatToNodeConfig(config) this.quill.formatText(range.index, range.length, config) } // 格式化所有文本 formatAllText(config = {}) { this.syncFormatToNodeConfig(config) this.pureFormatAllText(config) } // 纯粹的格式化所有文本 pureFormatAllText(config = {}) { this.quill.formatText(0, this.quill.getLength(), config) } // 同步格式化到节点样式配置 syncFormatToNodeConfig(config, clear) { if (!this.node) return if (clear) { // 清除文本样式 ;[ 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textDecoration', 'color' ].forEach(prop => { delete this.node.nodeData.data[prop] }) } else { let data = this.richTextStyleToNormalStyle(config) this.mindMap.execCommand('SET_NODE_DATA', this.node, data) } } // 将普通节点样式对象转换成富文本样式对象 normalStyleToRichTextStyle(style) { let config = {} Object.keys(style).forEach(prop => { let value = style[prop] switch (prop) { case 'fontFamily': config.font = value break case 'fontSize': config.size = value + 'px' break case 'fontWeight': config.bold = value === 'bold' break case 'fontStyle': config.italic = value === 'italic' break case 'textDecoration': config.underline = value === 'underline' config.strike = value === 'line-through' break case 'color': config.color = value break default: break } }) return config } // 将富文本样式对象转换成普通节点样式对象 richTextStyleToNormalStyle(config) { let data = {} Object.keys(config).forEach(prop => { let value = config[prop] switch (prop) { case 'font': data.fontFamily = value break case 'size': data.fontSize = parseFloat(value) break case 'bold': data.fontWeight = value ? 'bold' : 'normal' break case 'italic': data.fontStyle = value ? 'italic' : 'normal' break case 'underline': data.textDecoration = value ? 'underline' : 'none' break case 'strike': data.textDecoration = value ? 'line-through' : 'none' break case 'color': data.color = value break default: break } }) return data } // 给未激活的节点设置富文本样式 setNotActiveNodeStyle(node, style) { const config = this.normalStyleToRichTextStyle(style) if (Object.keys(config).length > 0) { this.showEditText({ node }) this.formatAllText(config) this.hideEditText([node]) } } // 检查指定节点是否存在自定义的富文本样式 checkNodeHasCustomRichTextStyle(node) { const list = [ 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textDecoration', 'color' ] const nodeData = node instanceof MindMapNode ? node.getData() : node for (let i = 0; i < list.length; i++) { if (nodeData[list[i]] !== undefined) { return true } } return false } // 将所有节点转换成非富文本节点 transformAllNodesToNormalNode() { if (!this.mindMap.renderer.renderTree) return walk( this.mindMap.renderer.renderTree, null, node => { if (node.data.richText) { node.data.richText = false node.data.text = getTextFromHtml(node.data.text) } // 概要 if (node.data) { const generalizationList = formatGetNodeGeneralization(node.data) generalizationList.forEach(item => { item.richText = false item.text = getTextFromHtml(item.text) }) } }, null, true, 0, 0 ) // 清空历史数据,并且触发数据变化 this.mindMap.command.clearHistory() this.mindMap.command.addHistory() this.mindMap.render(null, CONSTANTS.TRANSFORM_TO_NORMAL_NODE) } // 处理导入数据 handleSetData(data) { let walk = root => { if (root.data && !root.data.richText) { root.data.richText = true root.data.resetRichText = true } // 概要 if (root.data) { const generalizationList = formatGetNodeGeneralization(root.data) generalizationList.forEach(item => { item.richText = true item.resetRichText = true }) } if (root.children && root.children.length > 0) { Array.from(root.children).forEach(item => { walk(item) }) } } walk(data) return data } // 插件被移除前做的事情 beforePluginRemove() { this.transformAllNodesToNormalNode() document.head.removeChild(this.styleEl) this.unbindEvent() this.mindMap.removeAppendCss('richText') } // 插件被卸载前做的事情 beforePluginDestroy() { document.head.removeChild(this.styleEl) this.unbindEvent() } } RichText.instanceName = 'richText' export default RichText