import View from './core/view/View' import Event from './core/event/Event' import Render from './core/render/Render' import merge from 'deepmerge' import { theme, ThemeConfig } from './theme' import Style from './core/render/node/Style' import KeyCommand from './core/command/KeyCommand' import Command from './core/command/Command' import BatchExecution from './utils/BatchExecution' import { layoutValueList, CONSTANTS, ERROR_TYPES, cssContent } from './constants/constant' import { SVG } from '@svgdotjs/svg.js' import { simpleDeepClone, getObjectChangedProps, isUndef, handleGetSvgDataExtraContent, getNodeTreeBoundingRect, mergeTheme } from './utils' import { defaultTheme, checkIsNodeSizeIndependenceConfig } from './theme' import { defaultOpt } from './constants/defaultOptions' import { Node } from "./types" // 思维导图 interface MindMapOptions { el?: HTMLElement; data?: Node; layout?: string; theme?: string; themeConfig?: ThemeConfig; fit?: boolean; addHistoryOnInit?: boolean; associativeLineIsAlwaysAboveNode?: boolean; [key: string]: any; } export class MindMap { static instanceCount: number = 0; static pluginList: any[] = []; public opt: MindMapOptions; private el: HTMLElement; private width: number; private height: number; private initWidth: number; private initHeight: number; private cssEl: HTMLStyleElement | null; private cssTextMap: Record; private nodeInnerPrefixList: string[]; private svg: any; // SVG.js type private draw: any; private lineDraw: any; private nodeDraw: any; private associativeLineDraw: any; private otherDraw: any; private event: Event; private keyCommand: KeyCommand; private command: Command; private renderer: Render; private view: View; private batchExecution: BatchExecution; private elRect: DOMRect; private doExport?: any; private themeConfig?: ThemeConfig private demonstrate?: any private watermark?: any private commonCaches?: any public richText?: any static usePlugin = (plugin: any, opt = {}) => { if (MindMap.hasPlugin(plugin) !== -1) return MindMap plugin.pluginOpt = opt MindMap.pluginList.push(plugin) return MindMap } static hasPlugin = (plugin: any) => { return MindMap.pluginList.findIndex(item => { return item === plugin }) } // 定义新主题 static defineTheme = (name: string, config = {}) => { if ((theme as any)[name]) { return new Error('该主题名称已存在') } (theme as any)[name] = mergeTheme(defaultTheme, config) } // 移除主题 static removeTheme = (name: string) => { if ((theme as any)[name]) { (theme as any)[name] = null } } constructor(opt: Partial = {}) { MindMap.instanceCount++; // Merge options this.opt = this.handleOpt(merge(defaultOpt, opt)); // Preprocess node data this.opt.data = this.handleData(this.opt.data); // Container element this.el = this.opt.el; console.log(opt) if (!this.el) throw new Error('Missing container element el'); // Get container size position information this.getElRectInfo(); // Initial canvas size this.initWidth = this.width; this.initHeight = this.height; // Required CSS styles this.cssEl = null; this.cssTextMap = {}; this.nodeInnerPrefixList = []; // Initialize canvas this.initContainer(); // Initialize theme this.initTheme(); // Initialize cache data this.initCache(); // Event class this.event = new Event({ mindMap: this }); // Key command class this.keyCommand = new KeyCommand({ mindMap: this }); // Command class this.command = new Command({ mindMap: this }); // Render class this.renderer = new Render({ mindMap: this }); // View operation class this.view = new View({ mindMap: this }); // Batch execution class this.batchExecution = new BatchExecution(); // Register plugins MindMap.pluginList.forEach(plugin => { this.initPlugin(plugin); }); // Add required CSS styles this.addCss(); // Initial render this.render(this.opt.fit ? () => this.view.fit() : () => { }); // Add initial data to history stack if (this.opt.addHistoryOnInit && this.opt.data) { this.command.addHistory(); } } private handleOpt(opt: MindMapOptions): MindMapOptions { // Check layout configuration if (!layoutValueList.includes(opt.layout)) { opt.layout = CONSTANTS.LAYOUT.LOGICAL_STRUCTURE; } // Check theme configuration opt.theme = opt.theme && (theme as any)[opt.theme] ? opt.theme : 'default'; return opt; } private handleData(data: any): any { if (isUndef(data) || Object.keys(data).length <= 0) return null; data = simpleDeepClone(data || {}); // Root node cannot be collapsed if (data.data && !data.data.expand) { data.data.expand = true; } return data; } private initContainer(): void { const { associativeLineIsAlwaysAboveNode } = this.opt; // Add a class name to the container element this.el.classList.add('smm-mind-map-container'); const createAssociativeLineDraw = (): void => { this.associativeLineDraw = this.draw.group(); this.associativeLineDraw.addClass('smm-associative-line-container'); }; // Canvas this.svg = SVG().addTo(this.el).size(this.width, this.height); // Container this.draw = this.svg.group(); this.draw.addClass('smm-container'); // Node connection line container this.lineDraw = this.draw.group(); this.lineDraw.addClass('smm-line-container'); // Default below nodes if (!associativeLineIsAlwaysAboveNode) { createAssociativeLineDraw(); } // Node container this.nodeDraw = this.draw.group(); this.nodeDraw.addClass('smm-node-container'); // Association lines always above nodes if (associativeLineIsAlwaysAboveNode) { createAssociativeLineDraw(); } // Container for other content this.otherDraw = this.draw.group(); this.otherDraw.addClass('smm-other-container'); } // 清空各容器 clearDraw() { this.lineDraw.clear() this.associativeLineDraw.clear() this.nodeDraw.clear() this.otherDraw.clear() } // Class methods with type annotations appendCss(key: string, str: string): void { this.cssTextMap[key] = str; this.removeCss(); this.addCss(); } removeAppendCss(key: string): void { if (this.cssTextMap[key]) { delete this.cssTextMap[key]; this.removeCss(); this.addCss(); } } joinCss(): string { return ( cssContent + Object.keys(this.cssTextMap) .map(key => this.cssTextMap[key]) .join('\n') ); } addCss(): void { this.cssEl = document.createElement('style'); this.cssEl.type = 'text/css'; this.cssEl.innerHTML = this.joinCss(); document.head.appendChild(this.cssEl); } removeCss(): void { if (this.cssEl) document.head.removeChild(this.cssEl); } render(callback?: (() => void) | null, source: string = ''): void { this.batchExecution.push('render', () => { this.initTheme(); this.renderer.render(callback, source); }); } reRender(callback?: (() => void) | null, source: string = ''): void { this.renderer.reRender = true; this.renderer.clearCache(); this.clearDraw(); this.render(callback, source); } getElRectInfo(): void { this.elRect = this.el.getBoundingClientRect(); this.width = this.elRect.width; this.height = this.elRect.height; if (this.width <= 0 || this.height <= 0) { throw new Error('容器元素el的宽高不能为0'); } } resize(): void { const oldWidth = this.width; const oldHeight = this.height; this.getElRectInfo(); this.svg.size(this.width, this.height); if (oldWidth !== this.width || oldHeight !== this.height) { if (this.demonstrate) { if (!this.demonstrate.isInDemonstrate) { this.render(); } } else { this.render(); } } this.emit('resize'); } on(event: string, fn: (...args: any[]) => void): void { this.event.on(event, fn); } emit(event: string, ...args: any[]): void { this.event.emit(event, ...args); } off(event: string, fn: (...args: any[]) => void): void { this.event.off(event, fn); } initCache(): void { this.commonCaches = { measureCustomNodeContentSizeEl: null, measureRichtextNodeTextSizeEl: null }; } initTheme(): void { this.themeConfig = mergeTheme( (theme as any)[this.opt.theme] || theme.default, this.opt.themeConfig ); Style.setBackgroundStyle(this.el, this.themeConfig); } setTheme(theme: string, notRender: boolean = false): void { this.execCommand('CLEAR_ACTIVE_NODE'); this.opt.theme = theme; if (!notRender) { this.render(null, CONSTANTS.CHANGE_THEME); } this.emit('view_theme_change', theme); } getTheme(): string { return this.opt.theme; } setThemeConfig(config: ThemeConfig, notRender: boolean = false): void { const changedConfig = getObjectChangedProps(this.themeConfig, config); this.opt.themeConfig = config; if (!notRender) { const res = checkIsNodeSizeIndependenceConfig(changedConfig); this.render(null, res ? '' : CONSTANTS.CHANGE_THEME); } } // 获取自定义主题配置 getCustomThemeConfig() { return this.opt.themeConfig } // 获取某个主题配置值 getThemeConfig(prop?: keyof ThemeConfig): ThemeConfig | any { return prop === undefined ? this.themeConfig : this.themeConfig[prop]; } // 获取配置 getConfig(prop?: keyof MindMapOptions): MindMapOptions | any { return prop === undefined ? this.opt : this.opt[prop]; } // 更新配置 updateConfig(opt: Partial = {}) { this.emit('before_update_config', this.opt) const lastOpt = { ...this.opt } this.opt = this.handleOpt(merge.all([defaultOpt, this.opt, opt])) this.emit('after_update_config', this.opt, lastOpt) } // 获取当前布局结构 getLayout() { return this.opt.layout } // 设置布局结构 setLayout(layout: any, notRender = false) { // 检查布局配置 if (!layoutValueList.includes(layout)) { layout = CONSTANTS.LAYOUT.LOGICAL_STRUCTURE } this.opt.layout = layout this.view.reset() this.renderer.setLayout() if (!notRender) { this.render(null, CONSTANTS.CHANGE_LAYOUT) } this.emit('layout_change', layout) } // 执行命令 execCommand(...args: [name: string, ...args: any[]]) { this.command.exec(...args) } // 更新画布数据,如果新的数据是在当前画布节点数据基础上增删改查后形成的,那么可以使用该方法来更新画布数据 updateData(data: any) { this.emit('before_update_data', data) this.renderer.setData(data) this.render() this.command.addHistory() this.emit('update_data', data) } // 动态设置思维导图数据,纯节点数据 setData(data: any) { data = this.handleData(data) this.emit('before_set_data', data) this.opt.data = data this.execCommand('CLEAR_ACTIVE_NODE') this.command.clearHistory() this.command.addHistory() this.renderer.setData(data) this.reRender(() => { }, CONSTANTS.SET_DATA) this.emit('set_data', data) } // 动态设置思维导图数据,包括节点数据、布局、主题、视图 setFullData(data: any) { if (data.root) { this.setData(data.root) } if (data.layout) { this.setLayout(data.layout) } if (data.theme) { if (data.theme.template) { this.setTheme(data.theme.template) } if (data.theme.config) { this.setThemeConfig(data.theme.config) } } if (data.view) { this.view.setTransformData(data.view) } } // 获取思维导图数据,节点树、主题、布局等 getData(withConfig?: boolean): any { const nodeData = this.command.getCopyData(); let data: Record = {}; if (withConfig) { data = { layout: this.getLayout(), root: nodeData, theme: { template: this.getTheme(), config: this.getCustomThemeConfig() }, view: this.view.getTransformData() }; } else { data = nodeData; } return simpleDeepClone(data); } // 导出 async export(...args: any) { try { if (!this.doExport) { throw new Error('请注册Export插件!') } let result = await this.doExport.export(...args) return result } catch (error) { this.opt.errorHandler(ERROR_TYPES.EXPORT_ERROR, error) } } // 转换位置 toPos(x: number, y: number): { x: number; y: number } { return { x: x - this.elRect.left, y: y - this.elRect.top } } // 设置只读模式、编辑模式 setMode(mode: any) { if (![CONSTANTS.MODE.READONLY, CONSTANTS.MODE.EDIT].includes(mode)) { return } const isReadonly = mode === CONSTANTS.MODE.READONLY if (isReadonly === this.opt.readonly) return if (isReadonly) { // 如果处于编辑态,要隐藏所有的编辑框 if (this.renderer.textEdit.isShowTextEdit()) { this.renderer.textEdit.hideEditTextBox() this.command.originAddHistory() } // 取消当前激活的元素 this.execCommand('CLEAR_ACTIVE_NODE') } this.opt.readonly = isReadonly // 切换为编辑模式时,如果历史记录堆栈是空的,那么进行一次入栈操作 if (!isReadonly && this.command.history.length <= 0) { this.command.originAddHistory() } this.emit('mode_change', mode) } // 获取svg数据 getSvgData({ paddingX = 0, paddingY = 0, ignoreWatermark = false, addContentToHeader, addContentToFooter, node }: { paddingX?: number; paddingY?: number; ignoreWatermark?: boolean; addContentToHeader?: () => void; addContentToFooter?: () => void; node?: any; } = {}) { const { watermarkConfig, openPerformance } = this.opt // 如果开启了性能模式,那么需要先渲染所有节点 if (openPerformance) { this.renderer.forceLoadNode(node) } const { cssTextList, header, headerHeight, footer, footerHeight } = handleGetSvgDataExtraContent({ addContentToHeader, addContentToFooter }) const svg = this.svg const draw = this.draw // 保存原始信息 const origWidth = svg.width() const origHeight = svg.height() const origTransform = draw.transform() const elRect = this.elRect // 去除放大缩小的变换效果 draw.scale(1 / origTransform.scaleX, 1 / origTransform.scaleY) // 获取变换后的位置尺寸信息,其实是getBoundingClientRect方法的包装方法 const rect = draw.rbox() // 需要裁减的区域 let clipData = null if (node) { clipData = getNodeTreeBoundingRect( node, rect.x, rect.y, paddingX, paddingY ) } // 内边距 const fixHeight = 0 rect.width += paddingX * 2 rect.height += paddingY * 2 + fixHeight + headerHeight + footerHeight draw.translate(paddingX, paddingY) // 将svg设置为实际内容的宽高 svg.size(rect.width, rect.height) // 把实际内容变换 draw.translate(-rect.x + elRect.left, -rect.y + elRect.top) // 克隆一份数据 let clone = svg.clone() // 是否存在水印 const hasWatermark = this.watermark && this.watermark.hasWatermark() if (!ignoreWatermark && hasWatermark) { this.watermark.isInExport = true // 是否是仅导出时需要水印 const { onlyExport } = watermarkConfig // 是否需要重新绘制水印 const needReDrawWatermark = rect.width > origWidth || rect.height > origHeight // 如果实际图形宽高超出了屏幕宽高,且存在水印的话需要重新绘制水印,否则会出现超出部分没有水印的问题 if (needReDrawWatermark) { this.width = rect.width this.height = rect.height this.watermark.onResize() clone = svg.clone() this.width = origWidth this.height = origHeight this.watermark.onResize() } else if (onlyExport) { // 如果是仅导出时需要水印,那么需要进行绘制 this.watermark.onResize() clone = svg.clone() } // 如果是仅导出时需要水印,需要清除 if (onlyExport) { this.watermark.clear() } this.watermark.isInExport = false } // 添加必要的样式 [this.joinCss(), ...cssTextList].forEach(s => { clone.add(SVG(``)) }) // 附加内容 if (header && headerHeight > 0) { clone.findOne('.smm-container').translate(0, headerHeight) header.width(rect.width) header.y(paddingY) clone.add(header, 0) } if (footer && footerHeight > 0) { footer.width(rect.width) footer.y(rect.height - paddingY - footerHeight) clone.add(footer) } // 修正defs里定义的元素的id,因为clone时defs里的元素的id会继续递增,导致和内容中引用的id对不上 const defs = svg.find('defs') const defs2 = clone.find('defs') defs.forEach((def: any, defIndex: number) => { const def2 = defs2[defIndex] if (!def2) return const children = def.children() const children2 = def2.children() for (let i = 0; i < children.length; i++) { const child = children[i] const child2 = children2[i] if (child && child2) { child2.attr('id', child.attr('id')) } } }) // 恢复原先的大小和变换信息 svg.size(origWidth, origHeight) draw.transform(origTransform) return { svg: clone, // 思维导图图形的整体svg元素,包括:svg(画布容器)、g(实际的思维导图组) svgHTML: clone.svg(), // svg字符串 clipData, rect: { ...rect, // 思维导图图形未缩放时的位置尺寸等信息 ratio: rect.width / rect.height // 思维导图图形的宽高比 }, origWidth, // 画布宽度 origHeight, // 画布高度 scaleX: origTransform.scaleX, // 思维导图图形的水平缩放值 scaleY: origTransform.scaleY // 思维导图图形的垂直缩放值 } } // 添加插件 addPlugin(plugin: any, opt: any) { let index = MindMap.hasPlugin(plugin) if (index === -1) { MindMap.usePlugin(plugin, opt) } this.initPlugin(plugin) } // 移除插件 removePlugin(plugin: any) { let index = MindMap.hasPlugin(plugin) if (index !== -1) { MindMap.pluginList.splice(index, 1) if ((this as any)[plugin.instanceName]) { if ((this as any)[plugin.instanceName].beforePluginRemove) { (this as any)[plugin.instanceName].beforePluginRemove() } delete (this as any)[plugin.instanceName] } } } // 实例化插件 initPlugin(plugin: any) { if ((this as any)[plugin.instanceName]) return (this as any)[plugin.instanceName] = new plugin({ mindMap: this, pluginOpt: plugin.pluginOpt }) } // 销毁 destroy() { this.emit('beforeDestroy') // 清除节点编辑框 this.renderer.textEdit.hideEditTextBox() this.renderer.textEdit.removeTextEditEl() // 移除插件 ;[...MindMap.pluginList].forEach(plugin => { if ( (this as any)[plugin.instanceName] && (this as any)[plugin.instanceName].beforePluginDestroy ) { (this as any)[plugin.instanceName].beforePluginDestroy() } (this as any)[plugin.instanceName] = null }) // 解绑事件 this.event.unbind() // 移除画布节点 this.svg.remove() // 去除给容器元素设置的背景样式 Style.removeBackgroundStyle(this.el) // 移除给容器元素添加的类名 this.el.classList.remove('smm-mind-map-container') this.el.innerHTML = '' this.el = null this.removeCss() MindMap.instanceCount-- } } export * from "./types"