collect-system/packages/mindmap/src/index.ts

723 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string>;
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<MindMapOptions> = {}) {
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<MindMapOptions> = {}) {
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<string, unknown> = {};
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(`<style>${s}</style>`))
})
// 附加内容
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"