import { resizeImgSize, removeRichTextStyes, checkIsRichText, isUndef, createForeignObjectNode, addXmlns, generateColorByContent, camelCaseToHyphen, getNodeRichTextStyles } from '../../../utils' import { Image as SVGImage, SVG, A, G, Rect, Text } from '@svgdotjs/svg.js' import iconsSvg from '../../../svg/icons' import { noneRichTextNodeLineHeight } from '../../../constants/constant' // 测量svg文本宽高 const measureText = (text, style) => { const g = new G() const node = new Text().text(text) style.text(node) g.add(node) return g.bbox() } // 标签默认的样式 const defaultTagStyle = { radius: 3, // 标签矩形的圆角大小 fontSize: 12, // 字号,建议文字高度不要大于height fill: '', // 标签矩形的背景颜色 height: 20, // 标签矩形的高度 paddingX: 8 // 水平内边距,如果设置了width,将忽略该配置 //width: 30 // 标签矩形的宽度,如果不设置,默认以文字的宽度+paddingX*2为宽度 } // 创建图片节点 function createImgNode() { const img = this.getData('image') if (!img) { return } const imgSize = this.getImgShowSize() const node = new SVGImage().load(img).size(...imgSize) // 如果指定了加载失败显示的图片,那么加载一下图片检测是否失败 const { defaultNodeImage } = this.mindMap.opt if (defaultNodeImage) { const imgEl = new Image() imgEl.onerror = () => { node.load(defaultNodeImage) } imgEl.src = img } if (this.getData('imageTitle')) { node.attr('title', this.getData('imageTitle')) } node.on('click', e => { this.mindMap.emit('node_img_click', this, node, e) }) node.on('dblclick', e => { this.mindMap.emit('node_img_dblclick', this, e, node) }) node.on('mouseenter', e => { this.mindMap.emit('node_img_mouseenter', this, node, e) }) node.on('mouseleave', e => { this.mindMap.emit('node_img_mouseleave', this, node, e) }) node.on('mousemove', e => { this.mindMap.emit('node_img_mousemove', this, node, e) }) return { node, width: imgSize[0], height: imgSize[1] } } // 获取图片显示宽高 function getImgShowSize() { const { custom, width, height } = this.getData('imageSize') // 如果是自定义了图片的宽高,那么不受最大宽高限制 if (custom) return [width, height] return resizeImgSize( width, height, this.mindMap.themeConfig.imgMaxWidth, this.mindMap.themeConfig.imgMaxHeight ) } // 创建icon节点 function createIconNode() { let _data = this.getData() if (!_data.icon || _data.icon.length <= 0) { return [] } let iconSize = this.mindMap.themeConfig.iconSize return _data.icon.map(item => { let src = iconsSvg.getNodeIconListIcon( item, this.mindMap.opt.iconList || [] ) let node = null // svg图标 if (/^ { this.mindMap.emit('node_icon_click', this, item, e, node) }) node.on('mouseenter', e => { this.mindMap.emit('node_icon_mouseenter', this, item, e, node) }) node.on('mouseleave', e => { this.mindMap.emit('node_icon_mouseleave', this, item, e, node) }) return { node, width: iconSize, height: iconSize } }) } // 创建富文本节点 function createRichTextNode(specifyText) { const hasCustomWidth = this.hasCustomWidth() let text = typeof specifyText === 'string' ? specifyText : this.getData('text') let { textAutoWrapWidth, emptyTextMeasureHeightText } = this.mindMap.opt textAutoWrapWidth = hasCustomWidth ? this.customTextWidth : textAutoWrapWidth const g = new G() // 创建富文本结构,或复位富文本样式 let recoverText = false if (this.getData('resetRichText')) { delete this.nodeData.data.resetRichText recoverText = true } if (recoverText && !isUndef(text)) { if (checkIsRichText(text)) { // 如果是富文本那么移除内联样式 text = removeRichTextStyes(text) } else { // 非富文本则改为富文本结构 text = `

${text}

` } this.setData({ text }) } // 节点的富文本样式数据 const nodeTextStyleList = [] const nodeRichTextStyles = getNodeRichTextStyles(this) Object.keys(nodeRichTextStyles).forEach(prop => { nodeTextStyleList.push([prop, nodeRichTextStyles[prop]]) }) // 测量文本大小 if (!this.mindMap.commonCaches.measureRichtextNodeTextSizeEl) { this.mindMap.commonCaches.measureRichtextNodeTextSizeEl = document.createElement('div') this.mindMap.commonCaches.measureRichtextNodeTextSizeEl.style.position = 'fixed' this.mindMap.commonCaches.measureRichtextNodeTextSizeEl.style.left = '-999999px' this.mindMap.el.appendChild( this.mindMap.commonCaches.measureRichtextNodeTextSizeEl ) } const div = this.mindMap.commonCaches.measureRichtextNodeTextSizeEl // 应用节点的文本样式 nodeTextStyleList.forEach(([prop, value]) => { div.style[prop] = value }) div.style.lineHeight = 1.2 const html = `
${text}
` div.innerHTML = html const el = div.children[0] el.classList.add('smm-richtext-node-wrap') addXmlns(el) el.style.maxWidth = textAutoWrapWidth + 'px' if (hasCustomWidth) { el.style.width = this.customTextWidth + 'px' } else { el.style.width = '' } let { width, height } = el.getBoundingClientRect() // 如果文本为空,那么需要计算一个默认高度 if (height <= 0) { div.innerHTML = `

${emptyTextMeasureHeightText}

` let elTmp = div.children[0] elTmp.classList.add('smm-richtext-node-wrap') height = elTmp.getBoundingClientRect().height div.innerHTML = html } width = Math.min(Math.ceil(width) + 1, textAutoWrapWidth) // 修复getBoundingClientRect方法对实际宽度是小数的元素获取到的值是整数,导致宽度不够文本发生换行的问题 height = Math.ceil(height) g.attr('data-width', width) g.attr('data-height', height) const foreignObject = createForeignObjectNode({ el: div.children[0], width, height }) // 应用节点文本样式 // 进入文本编辑时,这个样式也会同样添加到文本编辑框的元素上 const foreignObjectStyle = { 'line-height': 1.2 } nodeTextStyleList.forEach(([prop, value]) => { foreignObjectStyle[camelCaseToHyphen(prop)] = value }) foreignObject.css(foreignObjectStyle) g.add(foreignObject) return { node: g, nodeContent: foreignObject, width, height } } // 创建文本节点 function createTextNode(specifyText) { if (this.getData('needUpdate')) { delete this.nodeData.data.needUpdate } // 如果是富文本内容,那么转给富文本函数 if (this.getData('richText')) { return this.createRichTextNode(specifyText) } const text = typeof specifyText === 'string' ? specifyText : this.getData('text') if (this.getData('resetRichText')) { delete this.nodeData.data.resetRichText } const g = new G() const fontSize = this.getStyle('fontSize', false) const textAlign = this.getStyle('textAlign', false) // 文本超长自动换行 let textArr = [] if (!isUndef(text)) { textArr = String(text).split(/\n/gim) } const { textAutoWrapWidth: maxWidth, emptyTextMeasureHeightText } = this.mindMap.opt let isMultiLine = textArr.length > 1 textArr.forEach((item, index) => { let arr = item.split('') let lines = [] let line = [] while (arr.length) { let str = arr.shift() let text = [...line, str].join('') if (measureText(text, this.style).width <= maxWidth) { line.push(str) } else { lines.push(line.join('')) line = [str] } } if (line.length > 0) { lines.push(line.join('')) } if (lines.length > 1) { isMultiLine = true } textArr[index] = lines.join('\n') }) textArr = textArr.join('\n').replace(/\n$/g, '').split(/\n/gim) textArr.forEach((item, index) => { // 避免尾部的空行不占宽度 // 同时解决该问题:https://github.com/wanglin2/mind-map/issues/1037 if (item === '') { item = '' } const node = new Text().text(item) node.addClass('smm-text-node-wrap') node.attr( 'text-anchor', { left: 'start', center: 'middle', right: 'end' }[textAlign] || 'start' ) this.style.text(node) node.y( fontSize * noneRichTextNodeLineHeight * index + ((noneRichTextNodeLineHeight - 1) * fontSize) / 2 ) g.add(node) }) let { width, height } = g.bbox() // 如果文本为空,那么需要计算一个默认高度 if (height <= 0) { const tmpNode = new Text().text(emptyTextMeasureHeightText) this.style.text(tmpNode) const tmpBbox = tmpNode.bbox() height = tmpBbox.height } width = Math.min(Math.ceil(width), maxWidth) height = Math.ceil(height) g.attr('data-width', width) g.attr('data-height', height) g.attr('data-ismultiLine', isMultiLine || textArr.length > 1) return { node: g, width, height } } // 创建超链接节点 function createHyperlinkNode() { const { hyperlink, hyperlinkTitle } = this.getData() if (!hyperlink) { return } const { customHyperlinkJump, hyperlinkIcon } = this.mindMap.opt const { icon, style } = hyperlinkIcon const iconSize = this.getNodeIconSize('hyperlinkIcon') const node = new SVG().size(iconSize, iconSize) // 超链接节点 const a = new A().to(hyperlink).target('_blank') a.node.addEventListener('click', e => { if (typeof customHyperlinkJump === 'function') { e.preventDefault() customHyperlinkJump(hyperlink, this) } }) if (hyperlinkTitle) { node.add(SVG(`${hyperlinkTitle}`)) } // 添加一个透明的层,作为鼠标区域 a.rect(iconSize, iconSize).fill({ color: 'transparent' }) // 超链接图标 const iconNode = SVG(icon || iconsSvg.hyperlink).size(iconSize, iconSize) this.style.iconNode(iconNode, style.color) a.add(iconNode) node.add(a) return { node, width: iconSize, height: iconSize } } // 创建标签节点 function createTagNode() { const tagData = this.getData('tag') if (!tagData || tagData.length <= 0) { return [] } let { maxTag, tagsColorMap } = this.mindMap.opt tagsColorMap = tagsColorMap || {} const nodes = [] tagData.slice(0, maxTag).forEach((item, index) => { let str = '' let style = { ...defaultTagStyle } // 旧版只支持字符串类型 if (typeof item === 'string') { str = item } else { // v0.10.3+版本支持对象类型 str = item.text style = { ...defaultTagStyle, ...item.style } } // 是否手动设置了标签宽度 const hasCustomWidth = typeof style.width !== 'undefined' // 创建容器节点 const tag = new G() tag.on('click', () => { this.mindMap.emit('node_tag_click', this, item, index, tag) }) // 标签文本 const text = new Text().text(str) this.style.tagText(text, style) // 获取文本宽高 const { width: textWidth, height: textHeight } = text.bbox() // 矩形宽度 const rectWidth = hasCustomWidth ? style.width : textWidth + style.paddingX * 2 // 取文本和矩形最大宽高作为标签宽高 const maxWidth = hasCustomWidth ? Math.max(rectWidth, textWidth) : rectWidth const maxHeight = Math.max(style.height, textHeight) // 文本居中 if (hasCustomWidth) { text.x((maxWidth - textWidth) / 2) } else { text.x(hasCustomWidth ? 0 : style.paddingX) } text.cy(-maxHeight / 2) // 标签矩形 const rect = new Rect().size(rectWidth, style.height).cy(-maxHeight / 2) if (hasCustomWidth) { rect.x((maxWidth - rectWidth) / 2) } this.style.tagRect(rect, { ...style, fill: style.fill || // 优先节点自身配置 tagsColorMap[text.node.textContent] || // 否则尝试从实例化选项tagsColorMap映射中获取颜色 generateColorByContent(text.node.textContent) // 否则按照标签内容生成 }) tag.add(rect).add(text) nodes.push({ node: tag, width: maxWidth, height: maxHeight }) }) return nodes } // 创建备注节点 function createNoteNode() { if (!this.getData('note')) { return null } const { icon, style } = this.mindMap.opt.noteIcon const iconSize = this.getNodeIconSize('noteIcon') const node = new SVG() .attr('cursor', 'pointer') .addClass('smm-node-note') .size(iconSize, iconSize) // 透明的层,用来作为鼠标区域 node.add(new Rect().size(iconSize, iconSize).fill({ color: 'transparent' })) // 备注图标 const iconNode = SVG(icon || iconsSvg.note).size(iconSize, iconSize) this.style.iconNode(iconNode, style.color) node.add(iconNode) // 备注tooltip if (!this.mindMap.opt.customNoteContentShow) { if (!this.noteEl) { this.noteEl = document.createElement('div') this.noteEl.style.cssText = ` position: fixed; padding: 10px; border-radius: 5px; box-shadow: 0 2px 5px rgb(0 0 0 / 10%); display: none; background-color: #fff; z-index: ${this.mindMap.opt.nodeNoteTooltipZIndex} ` const targetNode = this.mindMap.opt.customInnerElsAppendTo || document.body targetNode.appendChild(this.noteEl) } this.noteEl.innerText = this.getData('note') } node.on('mouseover', () => { const { left, top } = this.getNoteContentPosition() if (!this.mindMap.opt.customNoteContentShow) { this.noteEl.style.left = left + 'px' this.noteEl.style.top = top + 'px' this.noteEl.style.display = 'block' } else { this.mindMap.opt.customNoteContentShow.show( this.getData('note'), left, top, this ) } }) node.on('mouseout', () => { if (!this.mindMap.opt.customNoteContentShow) { this.noteEl.style.display = 'none' } else { this.mindMap.opt.customNoteContentShow.hide() } }) node.on('click', e => { this.mindMap.emit('node_note_click', this, e, node) }) node.on('dblclick', e => { this.mindMap.emit('node_note_dblclick', this, e, node) }) return { node, width: iconSize, height: iconSize } } // 创建附件节点 function createAttachmentNode() { const { attachmentUrl, attachmentName } = this.getData() if (!attachmentUrl) { return } const iconSize = this.getNodeIconSize('attachmentIcon') const { icon, style } = this.mindMap.opt.attachmentIcon const node = new SVG().attr('cursor', 'pointer').size(iconSize, iconSize) if (attachmentName) { node.add(SVG(`${attachmentName}`)) } // 透明的层,用来作为鼠标区域 node.add(new Rect().size(iconSize, iconSize).fill({ color: 'transparent' })) // 备注图标 const iconNode = SVG(icon || iconsSvg.attachment).size(iconSize, iconSize) this.style.iconNode(iconNode, style.color) node.add(iconNode) node.on('click', e => { this.mindMap.emit('node_attachmentClick', this, e, node) }) node.on('contextmenu', e => { this.mindMap.emit('node_attachmentContextmenu', this, e, node) }) return { node, width: iconSize, height: iconSize } } // 获取节点图标大小 function getNodeIconSize(prop) { const { style } = this.mindMap.opt[prop] return isUndef(style.size) ? this.mindMap.themeConfig.iconSize : style.size } // 获取节点备注显示位置 function getNoteContentPosition() { const iconSize = this.getNodeIconSize('noteIcon') const { scaleY } = this.mindMap.view.getTransformData().transform const iconSizeAddScale = iconSize * scaleY let { left, top } = this._noteData.node.node.getBoundingClientRect() top += iconSizeAddScale return { left, top } } // 测量自定义节点内容元素的宽高 function measureCustomNodeContentSize(content) { if (!this.mindMap.commonCaches.measureCustomNodeContentSizeEl) { this.mindMap.commonCaches.measureCustomNodeContentSizeEl = document.createElement('div') this.mindMap.commonCaches.measureCustomNodeContentSizeEl.style.cssText = ` position: fixed; left: -99999px; top: -99999px; ` this.mindMap.el.appendChild( this.mindMap.commonCaches.measureCustomNodeContentSizeEl ) } this.mindMap.commonCaches.measureCustomNodeContentSizeEl.innerHTML = '' this.mindMap.commonCaches.measureCustomNodeContentSizeEl.appendChild(content) let rect = this.mindMap.commonCaches.measureCustomNodeContentSizeEl.getBoundingClientRect() return { width: rect.width, height: rect.height } } // 是否使用的是自定义节点内容 function isUseCustomNodeContent() { return !!this._customNodeContent } export default { createImgNode, getImgShowSize, createIconNode, createRichTextNode, createTextNode, createHyperlinkNode, createTagNode, createNoteNode, createAttachmentNode, getNoteContentPosition, getNodeIconSize, measureCustomNodeContentSize, isUseCustomNodeContent }