mirror of
https://github.com/wanglin2/mind-map.git
synced 2026-02-22 02:47:46 +08:00
462 lines
14 KiB
JavaScript
462 lines
14 KiB
JavaScript
import Quill from 'quill'
|
||
import 'quill/dist/quill.snow.css'
|
||
import './css/quill.css'
|
||
import html2canvas from 'html2canvas'
|
||
import { Image as SvgImage } from '@svgdotjs/svg.js'
|
||
import { walk } from './utils'
|
||
import { CONSTANTS } from './utils/constant'
|
||
|
||
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.node = null
|
||
this.initOpt()
|
||
this.extendQuill()
|
||
}
|
||
|
||
// 处理选项参数
|
||
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
|
||
|
||
// 扩展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)
|
||
|
||
// 扩展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)
|
||
}
|
||
|
||
// 显示文本编辑控件
|
||
showEditText(node, rect) {
|
||
if (this.showTextEdit) {
|
||
return
|
||
}
|
||
this.node = node
|
||
if (!rect) rect = node._textData.node.node.getBoundingClientRect()
|
||
this.mindMap.emit('before_show_text_edit')
|
||
this.mindMap.renderer.textEdit.registerTmpShortcut()
|
||
if (!this.textEditNode) {
|
||
this.textEditNode = document.createElement('div')
|
||
this.textEditNode.style.cssText = `position:fixed;box-sizing: border-box;box-shadow: 0 0 20px rgba(0,0,0,.5);outline: none; word-break: break-all;padding: 3px 5px;margin-left: -5px;margin-top: -3px;`
|
||
document.body.appendChild(this.textEditNode)
|
||
}
|
||
// 原始宽高
|
||
let g = node._textData.node
|
||
let originWidth = g.attr('data-width')
|
||
let originHeight = g.attr('data-height')
|
||
// 使用节点的填充色,否则如果节点颜色是白色的话编辑时看不见
|
||
let bgColor = node.style.merge('fillColor')
|
||
this.textEditNode.style.backgroundColor = bgColor === 'transparent' ? '#fff' : bgColor
|
||
this.textEditNode.style.minWidth = originWidth + 'px'
|
||
this.textEditNode.style.minHeight = originHeight + 'px'
|
||
this.textEditNode.style.left =
|
||
rect.left + (rect.width - originWidth) / 2 + 'px'
|
||
this.textEditNode.style.top =
|
||
rect.top + (rect.height - originHeight) / 2 + 'px'
|
||
this.textEditNode.style.display = 'block'
|
||
this.textEditNode.style.maxWidth = this.mindMap.opt.textAutoWrapWidth + 'px'
|
||
this.textEditNode.style.transform = `scale(${rect.width / originWidth}, ${
|
||
rect.height / originHeight
|
||
})`
|
||
if (!node.nodeData.data.richText) {
|
||
// 还不是富文本的情况
|
||
let text = node.nodeData.data.text.split(/\n/gim).join('<br>')
|
||
let html = `<p>${text}</p>`
|
||
this.textEditNode.innerHTML = html
|
||
} else {
|
||
this.textEditNode.innerHTML = node.nodeData.data.text
|
||
}
|
||
this.initQuillEditor()
|
||
document.querySelector('.ql-editor').style.minHeight = originHeight + 'px'
|
||
this.showTextEdit = true
|
||
this.focus()
|
||
if (!node.nodeData.data.richText) {
|
||
// 如果是非富文本的情况,需要手动应用文本样式
|
||
this.setTextStyleIfNotRichText(node)
|
||
}
|
||
}
|
||
|
||
// 如果是非富文本的情况,需要手动应用文本样式
|
||
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.formatAllText(style)
|
||
}
|
||
|
||
// 隐藏文本编辑控件,即完成编辑
|
||
hideEditText() {
|
||
if (!this.showTextEdit) {
|
||
return
|
||
}
|
||
let html = this.quill.container.firstChild.innerHTML
|
||
// 去除最后的空行
|
||
html = html.replace(/<p><br><\/p>$/, '')
|
||
this.mindMap.renderer.activeNodeList.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,
|
||
this.mindMap.renderer.activeNodeList
|
||
)
|
||
this.textEditNode.style.display = 'none'
|
||
this.showTextEdit = false
|
||
this.mindMap.emit('rich_text_selection_change', false)
|
||
this.node = null
|
||
}
|
||
|
||
// 初始化Quill富文本编辑器
|
||
initQuillEditor() {
|
||
this.quill = new Quill(this.textEditNode, {
|
||
modules: {
|
||
toolbar: false,
|
||
keyboard: {
|
||
bindings: {
|
||
enter: {
|
||
key: 13,
|
||
handler: function () {
|
||
// 覆盖默认的回车键换行
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
theme: 'snow'
|
||
})
|
||
this.quill.on('selection-change', range => {
|
||
this.lastRange = this.range
|
||
this.range = null
|
||
if (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
|
||
)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 选中全部
|
||
selectAll() {
|
||
this.quill.setSelection(0, this.quill.getLength())
|
||
}
|
||
|
||
// 聚焦
|
||
focus() {
|
||
let len = this.quill.getLength()
|
||
this.quill.setSelection(len, len)
|
||
}
|
||
|
||
// 格式化当前选中的文本
|
||
formatText(config = {}, clear = false) {
|
||
if (!this.range && !this.lastRange) return
|
||
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)
|
||
}
|
||
|
||
// 格式化指定范围的文本
|
||
formatRangeText(range, config = {}) {
|
||
if (!range) return
|
||
this.syncFormatToNodeConfig(config)
|
||
this.quill.formatText(range.index, range.length, config)
|
||
}
|
||
|
||
// 格式化所有文本
|
||
formatAllText(config = {}) {
|
||
this.syncFormatToNodeConfig(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.renderer.setNodeData(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
|
||
}
|
||
|
||
// 将svg中嵌入的dom元素转换成图片
|
||
async _handleSvgDomElements(svg) {
|
||
svg = svg.clone()
|
||
let foreignObjectList = svg.find('foreignObject')
|
||
let task = foreignObjectList.map(async item => {
|
||
let clone = item.first().node.cloneNode(true)
|
||
let div = document.createElement('div')
|
||
div.style.cssText = `position: fixed; left: -999999px;`
|
||
div.appendChild(clone)
|
||
this.mindMap.el.appendChild(div)
|
||
let canvas = await html2canvas(clone, {
|
||
backgroundColor: null
|
||
})
|
||
this.mindMap.el.removeChild(div)
|
||
let imgNode = new SvgImage()
|
||
.load(canvas.toDataURL())
|
||
.size(canvas.width, canvas.height)
|
||
item.replace(imgNode)
|
||
})
|
||
await Promise.all(task)
|
||
return {
|
||
svg: svg,
|
||
svgHTML: svg.svg()
|
||
}
|
||
}
|
||
|
||
// 将svg中嵌入的dom元素转换成图片
|
||
handleSvgDomElements(svg) {
|
||
return new Promise((resolve, reject) => {
|
||
svg = svg.clone()
|
||
let foreignObjectList = svg.find('foreignObject')
|
||
let index = 0
|
||
let len = foreignObjectList.length
|
||
let transform = async () => {
|
||
this.mindMap.emit('transforming-dom-to-images', index, len)
|
||
try {
|
||
let item = foreignObjectList[index++]
|
||
let parent = item.parent()
|
||
let clone = item.first().node.cloneNode(true)
|
||
let div = document.createElement('div')
|
||
div.style.cssText = `position: fixed; left: -999999px;`
|
||
div.appendChild(clone)
|
||
this.mindMap.el.appendChild(div)
|
||
let canvas = await html2canvas(clone, {
|
||
backgroundColor: null
|
||
})
|
||
// 优先使用原始宽高,因为当设备的window.devicePixelRatio不为1时,html2canvas输出的图片会更大
|
||
let imgNodeWidth = parent.attr('data-width') || canvas.width
|
||
let imgNodeHeight = parent.attr('data-height') || canvas.height
|
||
this.mindMap.el.removeChild(div)
|
||
let imgNode = new SvgImage()
|
||
.load(canvas.toDataURL())
|
||
.size(imgNodeWidth, imgNodeHeight)
|
||
.x((parent ? parent.attr('data-offsetx') : 0) || 0)
|
||
item.replace(imgNode)
|
||
if (index <= len - 1) {
|
||
setTimeout(() => {
|
||
transform()
|
||
}, 0)
|
||
} else {
|
||
resolve({
|
||
svg: svg,
|
||
svgHTML: svg.svg()
|
||
})
|
||
}
|
||
} catch (error) {
|
||
reject(error)
|
||
}
|
||
}
|
||
if (len > 0) {
|
||
transform()
|
||
} else {
|
||
resolve(null)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 将所有节点转换成非富文本节点
|
||
transformAllNodesToNormalNode() {
|
||
let div = document.createElement('div')
|
||
walk(
|
||
this.mindMap.renderer.renderTree,
|
||
null,
|
||
node => {
|
||
if (node.data.richText) {
|
||
node.data.richText = false
|
||
div.innerHTML = node.data.text
|
||
node.data.text = div.textContent
|
||
// delete node.data.uid
|
||
}
|
||
},
|
||
null,
|
||
true,
|
||
0,
|
||
0
|
||
)
|
||
// 清空历史数据,并且触发数据变化
|
||
this.mindMap.command.clearHistory()
|
||
this.mindMap.command.addHistory()
|
||
this.mindMap.render(null, CONSTANTS.TRANSFORM_TO_NORMAL_NODE)
|
||
}
|
||
|
||
// 插件被移除前做的事情
|
||
beforePluginRemove() {
|
||
this.transformAllNodesToNormalNode()
|
||
}
|
||
}
|
||
|
||
RichText.instanceName = 'richText'
|
||
|
||
export default RichText
|