├── .gitignore ├── CONTRIBUTE.md ├── LICENSE ├── README.md ├── apps ├── core │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── index.d.ts │ ├── package.json │ ├── src │ │ ├── awareness-handler.ts │ │ ├── component │ │ │ ├── m-element.ts │ │ │ ├── toolbar │ │ │ │ ├── add-brother.ts │ │ │ │ ├── add-child.ts │ │ │ │ ├── create-toolbar-item.ts │ │ │ │ ├── delete.ts │ │ │ │ ├── edit.ts │ │ │ │ ├── redo.ts │ │ │ │ ├── target.ts │ │ │ │ ├── toolbar.ts │ │ │ │ └── undo.ts │ │ │ └── viewport-scale │ │ │ │ └── viewport-scale.ts │ │ ├── config.ts │ │ ├── data │ │ │ ├── add-handler.ts │ │ │ ├── change-father-handler.ts │ │ │ ├── data-handler.ts │ │ │ ├── data-helper.ts │ │ │ ├── remove-handler.ts │ │ │ └── undo-handler.ts │ │ ├── drag │ │ │ ├── drag-area.ts │ │ │ ├── drag-cloned-node.ts │ │ │ ├── drag-temp.ts │ │ │ ├── drag.ts │ │ │ └── draw-drag-area.ts │ │ ├── helper.ts │ │ ├── img │ │ │ ├── add-brother.svg │ │ │ ├── add-child.svg │ │ │ ├── delete.svg │ │ │ ├── edit.svg │ │ │ ├── redo.svg │ │ │ ├── target.svg │ │ │ ├── undo.svg │ │ │ ├── zoom-in.svg │ │ │ └── zoom-out.svg │ │ ├── index.less │ │ ├── index.ts │ │ ├── keyboard │ │ │ ├── keyboard-events.ts │ │ │ └── keyboard.ts │ │ ├── mobile.less │ │ ├── node-interaction.ts │ │ ├── node │ │ │ ├── expander.ts │ │ │ ├── node-creator.ts │ │ │ ├── node.ts │ │ │ └── shape-generator.ts │ │ ├── paper-wrapper.ts │ │ ├── position.ts │ │ ├── selection │ │ │ ├── multi-select.ts │ │ │ ├── pre-selection.ts │ │ │ ├── selection-arrow-next.ts │ │ │ ├── selection-boundary-move.ts │ │ │ ├── selection-remove-next.ts │ │ │ └── selection.ts │ │ ├── shape │ │ │ ├── collaborate-shape.ts │ │ │ ├── common │ │ │ │ ├── draw-edge.ts │ │ │ │ ├── node-shape-style.ts │ │ │ │ └── shape-event-emitter.ts │ │ │ ├── drag-temp-node-shape.ts │ │ │ ├── expander-shape.ts │ │ │ ├── first-edge-shape.ts │ │ │ ├── first-node-shape.ts │ │ │ ├── gap.ts │ │ │ ├── grandchild-edge-shape.ts │ │ │ ├── grandchild-node-shape.ts │ │ │ ├── multi-select-shape.ts │ │ │ ├── node-shape.ts │ │ │ └── root-node-shape.ts │ │ ├── text-editor.ts │ │ ├── tool-operation.ts │ │ ├── tree │ │ │ ├── tree-renderer.ts │ │ │ └── tree.ts │ │ ├── types.ts │ │ ├── viewport-interaction │ │ │ ├── drag-background.ts │ │ │ ├── drag-root.ts │ │ │ ├── drag-viewport-handler.ts │ │ │ ├── viewport-interaction.ts │ │ │ ├── viewport-resize.ts │ │ │ └── viewport-wheel.ts │ │ └── viewport.ts │ ├── tsconfig.json │ └── vite.config.ts └── page │ ├── collaborate-combine.html │ ├── collaborate-combine.ts │ ├── collaborate.html │ ├── collaborate.ts │ ├── common │ ├── common.less │ ├── helper.ts │ ├── init-page.ts │ ├── mobile-alert.ts │ └── store.ts │ ├── data │ └── resume-data.ts │ ├── demo.html │ ├── demo.ts │ ├── img │ ├── close.svg │ ├── github.svg │ ├── info.svg │ └── resume │ │ ├── advanced.png │ │ ├── baseball.png │ │ └── front.gif │ ├── index.html │ ├── index.ts │ ├── package.json │ ├── resume.html │ ├── resume.ts │ ├── tsconfig.json │ └── vite.config.ts ├── build ├── core-vite.config.ts ├── deploy.sh └── page-vite.config.ts ├── package.json ├── packages └── tsconfig │ ├── package.json │ └── vite.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── turbo.json └── wiki ├── README.zh.md ├── demo.jpg ├── resume.pdf └── resume.png /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | node_modules/ 3 | .idea/ 4 | dist/ 5 | docs/ 6 | .DS_Store 7 | package-lock.json 8 | core/README.md 9 | core/LICENSE 10 | .turbo/ 11 | .next/ -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTE 2 | 3 | ## Develop 4 | ```sh 5 | npm install 6 | npm start 7 | ``` 8 | 9 | Access: http://localhost:5173/mindmaptree 10 | 11 | ## Publish 12 | 13 | #### publish core npm 14 | 15 | Build core: 16 | ```sh 17 | npm run build:core 18 | ``` 19 | 20 | Alter package.json version,then publish: 21 | 22 | ```sh 23 | cd core 24 | npm publish 25 | ``` 26 | 27 | #### publish page 28 | git checkout to `gh-pages` branch,then run: 29 | 30 | ```sh 31 | npm run build 32 | git add . 33 | git commit -m "some commit" 34 | git push origin gh-pages 35 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 - present RockyRen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Mindmap-Tree 3 |

4 | 5 |

6 | A Web-Based Javascript Mindmap 7 |

8 | 9 |

10 | 11 | npm 12 | 13 | 14 | ci 15 | 16 | 17 |

18 | 19 |

20 | 简体中文文档 21 |

22 | 23 | [![mindmap-tree demo](https://rockyren.github.io/mindmaptree/assets/wiki/demo.jpg)](https://rockyren.github.io/mindmaptree/demo.html) 24 | 25 | 26 | ## Demo 27 | [Demo](https://rockyren.github.io/mindmaptree/demo.html) 28 | 29 | 30 | ## Feature 31 | 32 | * Add & Delete Node 33 | * Edit Node Text 34 | * Undo & Redo 35 | * Change Scale 36 | * Drag Node to change Father 37 | * Keyboard operation 38 | * Multi select 39 | * Expand & Shrink Node 40 | 41 | 42 | ## Get Started 43 | 44 | ### Installation 45 | 46 | ```sh 47 | npm install -S mindmap-tree 48 | ``` 49 | 50 | ### Usage 51 | 52 | ```html 53 | 54 |
55 | 56 | ``` 57 | 58 | ```js 59 | import MindmapTree from 'mindmap-tree'; 60 | import 'mindmap-tree/style.css'; 61 | 62 | new MindmapTree({ 63 | container: '#container', 64 | }); 65 | ``` 66 | 67 | ### Params 68 | 69 | MindmapTree constructor options: 70 | 71 | | Prop | Type | Default | Description | 72 | | ------------- | :---------------: | ------------------------ | ------------------------- | 73 | | **container** | String \| Element | '' | HTML element of container | 74 | | **data** | NodeDataMap | Record | Initial data of mindmap | 75 | | **isDebug** | Boolean | false | Is debug or not | 76 | 77 | NodeData params: 78 | 79 | | Prop | Type | Default | Description | 80 | | ------------- | :------: | ------- | ---------------------------------------- | 81 | | **label** | String | '' | Node label | 82 | | **direction** | Number | 0 | Node direction, 1:right, 0:none, -1:left | 83 | | **isRoot** | Boolean | false | Is root node or not | 84 | | **children** | String[] | [] | children ids | 85 | | **isExpand** | Boolean | true | To expand node or not | 86 | 87 | ## License 88 | 89 | [MIT](https://github.com/RockyRen/mindmaptree/blob/master/LICENSE) 90 | 91 | Copyright (c) 2023 - present, RockyRen 92 | -------------------------------------------------------------------------------- /apps/core/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tsconfig.json 3 | vite.config.ts -------------------------------------------------------------------------------- /apps/core/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 - present RockyRen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /apps/core/README.md: -------------------------------------------------------------------------------- 1 |

2 | Mindmap-Tree 3 |

4 | 5 |

6 | A Web-Based Javascript Mindmap 7 |

8 | 9 |

10 | 11 | npm 12 | 13 | 14 | ci 15 | 16 | 17 |

18 | 19 |

20 | 简体中文文档 21 |

22 | 23 | [![mindmap-tree demo](https://rockyren.github.io/mindmaptree/assets/wiki/demo.jpg)](https://rockyren.github.io/mindmaptree/demo.html) 24 | 25 | 26 | ## Demo 27 | [Demo](https://rockyren.github.io/mindmaptree/demo.html) 28 | 29 | 30 | ## Feature 31 | 32 | * Add & Delete Node 33 | * Edit Node Text 34 | * Undo & Redo 35 | * Change Scale 36 | * Drag Node to change Father 37 | * Keyboard operation 38 | * Multi select 39 | * Expand & Shrink Node 40 | 41 | 42 | ## Get Started 43 | 44 | ### Installation 45 | 46 | ```sh 47 | npm install -S mindmap-tree 48 | ``` 49 | 50 | ### Usage 51 | 52 | ```html 53 | 54 |
55 | 56 | ``` 57 | 58 | ```js 59 | import MindmapTree from 'mindmap-tree'; 60 | import 'mindmap-tree/style.css'; 61 | 62 | new MindmapTree({ 63 | container: '#container', 64 | }); 65 | ``` 66 | 67 | ### Params 68 | 69 | MindmapTree constructor options: 70 | 71 | | Prop | Type | Default | Description | 72 | | ------------- | :---------------: | ------------------------ | ------------------------- | 73 | | **container** | String \| Element | '' | HTML element of container | 74 | | **data** | NodeDataMap | Record | Initial data of mindmap | 75 | | **isDebug** | Boolean | false | Is debug or not | 76 | 77 | NodeData params: 78 | 79 | | Prop | Type | Default | Description | 80 | | ------------- | :------: | ------- | ---------------------------------------- | 81 | | **label** | String | '' | Node label | 82 | | **direction** | Number | 0 | Node direction, 1:right, 0:none, -1:left | 83 | | **isRoot** | Boolean | false | Is root node or not | 84 | | **children** | String[] | [] | children ids | 85 | | **isExpand** | Boolean | true | To expand node or not | 86 | 87 | ## License 88 | 89 | [MIT](https://github.com/RockyRen/mindmaptree/blob/master/LICENSE) 90 | 91 | Copyright (c) 2023 - present, RockyRen 92 | -------------------------------------------------------------------------------- /apps/core/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface ImageData { 2 | src: string; 3 | width: number; 4 | height: number; 5 | gap?: number; 6 | toward: 'left' | 'right'; 7 | } 8 | 9 | export interface NodeData { 10 | children: string[]; 11 | label: string; 12 | direction: -1 | 0 | 1; 13 | isRoot?: boolean; 14 | isExpand?: boolean; 15 | imageData?: ImageData; 16 | link?: string; 17 | } 18 | 19 | export type NodeDataMap = Record; 20 | 21 | export interface EventMap { 22 | data: (data: NodeDataMap) => void; 23 | } 24 | 25 | export type EventNames = keyof EventMap; 26 | 27 | declare class MindmapTree { 28 | public constructor(options: { 29 | container: string | Element; 30 | data?: NodeDataMap; 31 | isDebug?: boolean; 32 | scale?: number; 33 | }) 34 | public on(eventName: T, callback: EventMap[T]): void 35 | public clear(): void 36 | } 37 | 38 | export default MindmapTree; 39 | -------------------------------------------------------------------------------- /apps/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mindmap-tree", 3 | "version": "0.0.10", 4 | "description": "A Web-Based Javascript Mindmap implements by svg", 5 | "main": "dist/index.umd.js", 6 | "module": "dist/index.mjs", 7 | "types": "./index.d.ts", 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "vite build" 11 | }, 12 | "exports": { 13 | ".": { 14 | "import": "./dist/index.mjs", 15 | "require": "./dist/index.umd.cjs", 16 | "types": "./index.d.ts" 17 | }, 18 | "./dist/": "./dist/", 19 | "./style.css": "./dist/style.css" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/RockyRen/mindmaptree.git" 24 | }, 25 | "homepage": "https://github.com/RockyRen/mindmaptree#readme", 26 | "bugs": { 27 | "url": "https://github.com/RockyRen/mindmaptree/issues" 28 | }, 29 | "keywords": [ 30 | "mindmap", 31 | "mindmap-tree" 32 | ], 33 | "author": "RockyRen", 34 | "license": "MIT", 35 | "dependencies": { 36 | "eventemitter3": "^5.0.0", 37 | "raphael": "^2.3.0", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "uuid": "^8.3.2", 41 | "y-protocols": "^1.0.5", 42 | "yjs": "^13.6.2" 43 | }, 44 | "devDependencies": { 45 | "@types/raphael": "^2.3.3", 46 | "@types/react": "^18.0.15", 47 | "@types/react-dom": "^18.0.6", 48 | "@vitejs/plugin-react": "^3.1.0", 49 | "less": "^4.1.3", 50 | "tsconfig": "workspace:*", 51 | "typescript": "^4.9.5", 52 | "vite": "^4.1.1" 53 | } 54 | } -------------------------------------------------------------------------------- /apps/core/src/awareness-handler.ts: -------------------------------------------------------------------------------- 1 | import Selection from './selection/selection'; 2 | import type { Awareness } from "y-protocols/awareness"; 3 | import type Node from './node/node'; 4 | import type PaperWrapper from './paper-wrapper'; 5 | 6 | interface UserSelectItem { 7 | user: string; 8 | color: string; 9 | selectId: string; 10 | } 11 | 12 | export const userConfig: { name: string; color: string; }[] = [ 13 | { name: 'Alice', color: '#30bced' }, 14 | { name: 'Bob', color: '#6eeb83' }, 15 | { name: 'Ben', color: '#ffbc42' }, 16 | { name: 'Lily', color: '#ecd444' }, 17 | { name: 'Juicy', color: '#ee6352' }, 18 | { name: 'Nana', color: '#9ac2c9' }, 19 | { name: 'Edwin', color: '#8acb88' }, 20 | { name: 'Jimmy', color: '#1be7ff' }, 21 | ]; 22 | 23 | const accessTime = (Number(localStorage.getItem('accessTime')) || 0) + 1; 24 | localStorage.setItem('accessTime', `${accessTime}`); 25 | 26 | class AwarenessHandler { 27 | private userSelectList: UserSelectItem[] = []; 28 | public constructor( 29 | awareness: Awareness, 30 | private readonly selection: Selection, 31 | private readonly root: Node, 32 | private readonly paperWrapper: PaperWrapper, 33 | ) { 34 | const currentUser = userConfig[accessTime % userConfig.length]; 35 | this.createUserNameDom(currentUser.name); 36 | 37 | awareness.on('change', () => { 38 | // todo 暂时使用定时器的方式,确保在render后面才设置select样式 39 | setTimeout(() => { 40 | const values = Array.from(awareness.getStates().values()); 41 | const userSelectList = values.reduce((list, value) => { 42 | const user = Object.keys(value)?.[0]; 43 | if (user) { 44 | const selectId = value[user]?.selectId || ''; 45 | const color = value[user]?.color || ''; 46 | list.push({ 47 | user, 48 | color, 49 | selectId, 50 | }); 51 | } 52 | return list; 53 | }, [] as UserSelectItem[]) as UserSelectItem[]; 54 | 55 | const nodeMap = this.getNodeMap(); 56 | 57 | // 清空旧的style 58 | this.userSelectList.forEach(({ selectId }) => { 59 | const node = nodeMap[selectId]; 60 | node?.setCollaborateStyle(null); 61 | }); 62 | 63 | // 设置新的style 64 | userSelectList.forEach(({ user, selectId, color }) => { 65 | if (user === currentUser.name) return; 66 | const node = nodeMap[selectId]; 67 | node?.setCollaborateStyle({ 68 | name: user, 69 | color, 70 | }); 71 | }); 72 | 73 | this.userSelectList = userSelectList; 74 | }, 200); 75 | }); 76 | 77 | this.selection.on('select', () => { 78 | const selectNodes = this.selection.getSelectNodes(); 79 | awareness.setLocalStateField(currentUser.name, { 80 | selectId: selectNodes[0]?.id || '', 81 | color: currentUser.color, 82 | timestamp: Date.now(), 83 | }); 84 | }); 85 | } 86 | 87 | private getNodeMap = (): Record => { 88 | const nodeMap: Record = {}; 89 | this.getNodeMapInner(this.root, nodeMap); 90 | return nodeMap; 91 | } 92 | 93 | private getNodeMapInner = (node: Node, nodeMap: Record): void => { 94 | if (!node) return; 95 | nodeMap[node.id] = node; 96 | 97 | node.children?.forEach((child) => { 98 | this.getNodeMapInner(child, nodeMap) !== null; 99 | }); 100 | } 101 | 102 | private createUserNameDom(username: string): void { 103 | const wrapperDom = this.paperWrapper.getWrapperDom(); 104 | const usernameDom = document.createElement('div'); 105 | usernameDom.style.position = 'fixed'; 106 | usernameDom.style.top = '34px'; 107 | usernameDom.style.left = '30px'; 108 | usernameDom.innerHTML = `user: ${username}`; 109 | wrapperDom.appendChild(usernameDom) 110 | } 111 | } 112 | 113 | export default AwarenessHandler; 114 | -------------------------------------------------------------------------------- /apps/core/src/component/m-element.ts: -------------------------------------------------------------------------------- 1 | 2 | class MElement { 3 | private dom: HTMLElement; 4 | public constructor(tag: string | HTMLElement, className: string = '') { 5 | if (typeof tag === 'string') { 6 | this.dom = document.createElement(tag); 7 | this.dom.className = className; 8 | } else { 9 | this.dom = tag; 10 | } 11 | } 12 | 13 | public getDom(): HTMLElement { 14 | return this.dom; 15 | } 16 | 17 | public setChildren(...eles: MElement[]): MElement { 18 | eles.forEach(ele => this.setChild(ele)); 19 | return this; 20 | } 21 | 22 | public setChild(arg: string | MElement): MElement { 23 | const ele: Text | HTMLElement = typeof arg === 'string' 24 | ? document.createTextNode(arg) 25 | : arg.dom; 26 | this.dom.appendChild(ele); 27 | return this; 28 | } 29 | 30 | public setClassName(v: string): MElement { 31 | this.dom.className = v; 32 | return this; 33 | } 34 | 35 | public addClass(name: string): MElement { 36 | this.dom.classList.add(name); 37 | return this; 38 | } 39 | 40 | public hasClass(name: string): boolean { 41 | return this.dom.classList.contains(name); 42 | } 43 | 44 | public removeClass(name: string): MElement { 45 | this.dom.classList.remove(name); 46 | return this; 47 | } 48 | 49 | 50 | public setHtml(content: string): MElement { 51 | this.dom.innerHTML = content; 52 | return this; 53 | } 54 | 55 | public setCss(style: Record): MElement { 56 | Object.keys(style).forEach((name: string) => { 57 | // @ts-ignore 58 | this.dom.style[name] = style[name]; 59 | }); 60 | return this; 61 | } 62 | 63 | public addEventListener(...params: Parameters): void { 64 | this.dom.addEventListener(...params); 65 | } 66 | 67 | public removeEventListener(...params: Parameters): void { 68 | this.dom.removeEventListener(...params); 69 | } 70 | } 71 | 72 | const h = (tag: string | HTMLElement, className = '') => new MElement(tag, className); 73 | 74 | export { 75 | MElement, 76 | h, 77 | }; 78 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/add-brother.ts: -------------------------------------------------------------------------------- 1 | 2 | import Selection from '../../selection/selection'; 3 | import ToolOperation from '../../tool-operation'; 4 | import { MElement } from '../m-element'; 5 | import { createToolbarItem } from './create-toolbar-item'; 6 | 7 | class AddChild { 8 | public readonly el: MElement; 9 | public readonly btnEl: MElement; 10 | private readonly toolOperation: ToolOperation; 11 | private readonly selection: Selection; 12 | constructor({ 13 | toolOperation, 14 | selection, 15 | }: { 16 | toolOperation: ToolOperation; 17 | selection: Selection; 18 | }) { 19 | this.toolOperation = toolOperation; 20 | this.selection = selection; 21 | const elements = this.element(); 22 | this.el = elements.el; 23 | this.btnEl = elements.btnEl; 24 | } 25 | 26 | public setState(): void { 27 | const selectNodes = this.selection.getSelectNodes(); 28 | if (selectNodes.length === 1) { 29 | this.btnEl.removeClass('disabled'); 30 | } else { 31 | this.btnEl.addClass('disabled'); 32 | } 33 | } 34 | 35 | private element(): { 36 | el: MElement; 37 | btnEl: MElement; 38 | } { 39 | const { 40 | el, 41 | btnEl, 42 | } = createToolbarItem({ 43 | iconName: 'add-brother', 44 | tipLabel: 'Add Topic', 45 | }); 46 | 47 | btnEl.addEventListener('click', () => { 48 | this.toolOperation.addBrotherNode(); 49 | }, false); 50 | 51 | return { 52 | el, 53 | btnEl, 54 | }; 55 | } 56 | } 57 | 58 | export default AddChild; 59 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/add-child.ts: -------------------------------------------------------------------------------- 1 | import ToolOperation from '../../tool-operation'; 2 | import Selection from '../../selection/selection'; 3 | import { MElement } from '../m-element'; 4 | import { createToolbarItem } from './create-toolbar-item'; 5 | 6 | class AddChild { 7 | public readonly el: MElement; 8 | public readonly btnEl: MElement; 9 | private readonly toolOperation: ToolOperation; 10 | private readonly selection: Selection; 11 | constructor({ 12 | toolOperation, 13 | selection, 14 | }: { 15 | toolOperation: ToolOperation; 16 | selection: Selection; 17 | }) { 18 | this.toolOperation = toolOperation; 19 | this.selection = selection; 20 | const elements = this.element(); 21 | this.el = elements.el; 22 | this.btnEl = elements.btnEl; 23 | } 24 | 25 | public setState(): void { 26 | const selectNodes = this.selection.getSelectNodes(); 27 | if (selectNodes.length === 1) { 28 | this.btnEl.removeClass('disabled'); 29 | } else { 30 | this.btnEl.addClass('disabled'); 31 | } 32 | } 33 | 34 | private element(): { 35 | el: MElement; 36 | btnEl: MElement; 37 | } { 38 | const { 39 | el, 40 | btnEl, 41 | } = createToolbarItem({ 42 | iconName: 'add-child', 43 | tipLabel: 'Add Subtopic', 44 | }); 45 | 46 | btnEl.addEventListener('click', () => { 47 | this.toolOperation.addChildNode(); 48 | }, false); 49 | 50 | return { 51 | el, 52 | btnEl, 53 | }; 54 | } 55 | } 56 | 57 | export default AddChild; 58 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/create-toolbar-item.ts: -------------------------------------------------------------------------------- 1 | import { h, MElement } from '../m-element'; 2 | 3 | export const createToolbarItem = ({ 4 | iconName, 5 | tipLabel, 6 | isDisabled = true, 7 | }: { 8 | iconName: string; 9 | tipLabel: string; 10 | isDisabled?: boolean 11 | }): { 12 | el: MElement; 13 | btnEl: MElement; 14 | } => { 15 | const btnEl = h('div', `toolbar-btn${isDisabled ? ' disabled' : ''}`).setChild( 16 | h('div', `toolbar-icon ${iconName}-icon`) 17 | ); 18 | const el = h('div', 'toolbar-item').setChildren( 19 | btnEl, 20 | h('div', 'toolbar-tip').setChild(tipLabel), 21 | ); 22 | 23 | return { 24 | el, 25 | btnEl, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/delete.ts: -------------------------------------------------------------------------------- 1 | import ToolOperation from '../../tool-operation'; 2 | import Selection from '../../selection/selection'; 3 | import { MElement } from '../m-element'; 4 | import { createToolbarItem } from './create-toolbar-item'; 5 | 6 | class Delete { 7 | public readonly el: MElement; 8 | public readonly btnEl: MElement; 9 | private readonly toolOperation: ToolOperation; 10 | private readonly selection: Selection; 11 | constructor({ 12 | toolOperation, 13 | selection, 14 | }: { 15 | toolOperation: ToolOperation; 16 | selection: Selection; 17 | }) { 18 | this.toolOperation = toolOperation; 19 | this.selection = selection; 20 | const elements = this.element(); 21 | this.el = elements.el; 22 | this.btnEl = elements.btnEl; 23 | } 24 | 25 | public setState(): void { 26 | const selectNodes = this.selection.getSelectNodes(); 27 | if (selectNodes.length > 0) { 28 | this.btnEl.removeClass('disabled'); 29 | } else { 30 | this.btnEl.addClass('disabled'); 31 | } 32 | } 33 | 34 | private element(): { 35 | el: MElement; 36 | btnEl: MElement; 37 | } { 38 | const { 39 | el, 40 | btnEl, 41 | } = createToolbarItem({ 42 | iconName: 'delete', 43 | tipLabel: 'Delete', 44 | }); 45 | 46 | btnEl.addEventListener('click', () => { 47 | this.toolOperation.removeNode(); 48 | }, false); 49 | 50 | return { 51 | el, 52 | btnEl, 53 | }; 54 | } 55 | } 56 | 57 | export default Delete; 58 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/edit.ts: -------------------------------------------------------------------------------- 1 | import TextEditor from '../../text-editor'; 2 | import Selection from '../../selection/selection'; 3 | import { MElement } from '../m-element'; 4 | import { createToolbarItem } from './create-toolbar-item'; 5 | 6 | class Edit { 7 | public readonly el: MElement; 8 | public readonly btnEl: MElement; 9 | private readonly textEditor: TextEditor; 10 | private readonly selection: Selection; 11 | constructor({ 12 | textEditor, 13 | selection, 14 | }: { 15 | textEditor: TextEditor; 16 | selection: Selection; 17 | }) { 18 | this.textEditor = textEditor; 19 | this.selection = selection; 20 | const elements = this.element(); 21 | this.el = elements.el; 22 | this.btnEl = elements.btnEl; 23 | } 24 | 25 | public setState(): void { 26 | const selectNodes = this.selection.getSelectNodes(); 27 | if (selectNodes.length === 1) { 28 | this.btnEl.removeClass('disabled'); 29 | } else { 30 | this.btnEl.addClass('disabled'); 31 | } 32 | } 33 | 34 | private element(): { 35 | el: MElement; 36 | btnEl: MElement; 37 | } { 38 | const { 39 | el, 40 | btnEl, 41 | } = createToolbarItem({ 42 | iconName: 'edit', 43 | tipLabel: 'Edit Text', 44 | }); 45 | 46 | btnEl.addEventListener('click', () => { 47 | this.textEditor.showBySelectionLabel(); 48 | }, false); 49 | 50 | return { 51 | el, 52 | btnEl, 53 | }; 54 | } 55 | } 56 | 57 | export default Edit; 58 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/redo.ts: -------------------------------------------------------------------------------- 1 | import DataHandler from '../../data/data-handler'; 2 | import ToolOperation from '../../tool-operation'; 3 | import { MElement } from '../m-element'; 4 | import { createToolbarItem } from './create-toolbar-item'; 5 | 6 | class Redo { 7 | public readonly el: MElement; 8 | public readonly btnEl: MElement; 9 | private readonly dataHandler: DataHandler; 10 | private readonly toolOperation: ToolOperation; 11 | constructor({ 12 | toolOperation, 13 | dataHandler, 14 | }: { 15 | toolOperation: ToolOperation; 16 | dataHandler: DataHandler; 17 | }) { 18 | this.toolOperation = toolOperation; 19 | this.dataHandler = dataHandler; 20 | const elements = this.element(); 21 | this.el = elements.el; 22 | this.btnEl = elements.btnEl; 23 | } 24 | 25 | public setState(): void { 26 | if (this.dataHandler.canRedo()) { 27 | this.btnEl.removeClass('disabled'); 28 | } else { 29 | this.btnEl.addClass('disabled'); 30 | } 31 | } 32 | 33 | private element(): { 34 | el: MElement; 35 | btnEl: MElement; 36 | } { 37 | const { 38 | el, 39 | btnEl, 40 | } = createToolbarItem({ 41 | iconName: 'redo', 42 | tipLabel: 'Redo', 43 | }); 44 | 45 | btnEl.addEventListener('click', () => { 46 | this.toolOperation.redo(); 47 | }, false); 48 | 49 | return { 50 | el, 51 | btnEl, 52 | }; 53 | } 54 | } 55 | 56 | export default Redo; 57 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/target.ts: -------------------------------------------------------------------------------- 1 | import ViewportInteraction from '../../viewport-interaction/viewport-interaction'; 2 | import { MElement } from '../m-element'; 3 | import { createToolbarItem } from './create-toolbar-item'; 4 | 5 | class Target { 6 | public readonly el: MElement; 7 | public readonly btnEl: MElement; 8 | private readonly viewportInteraction: ViewportInteraction; 9 | constructor({ 10 | viewportInteraction, 11 | }: { 12 | viewportInteraction: ViewportInteraction 13 | }) { 14 | this.viewportInteraction = viewportInteraction; 15 | const elements = this.element(); 16 | this.el = elements.el; 17 | this.btnEl = elements.btnEl; 18 | } 19 | 20 | private element(): { 21 | el: MElement; 22 | btnEl: MElement; 23 | } { 24 | const { 25 | el, 26 | btnEl, 27 | } = createToolbarItem({ 28 | iconName: 'target', 29 | tipLabel: 'Locate to center', 30 | isDisabled: false, 31 | }); 32 | 33 | btnEl.addEventListener('click', () => { 34 | this.viewportInteraction.translateToCenter(); 35 | }, false); 36 | 37 | return { 38 | el, 39 | btnEl, 40 | }; 41 | } 42 | } 43 | 44 | export default Target; 45 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/toolbar.ts: -------------------------------------------------------------------------------- 1 | import Selection from '../../selection/selection'; 2 | import DataHandler from '../../data/data-handler'; 3 | import TextEditor from '../../text-editor'; 4 | import ToolOperation from '../../tool-operation'; 5 | import PaperWrapper from '../../paper-wrapper'; 6 | import ViewportInteraction from '../../viewport-interaction/viewport-interaction'; 7 | import { h, MElement } from '../m-element'; 8 | import Undo from './undo'; 9 | import Redo from './redo'; 10 | import AddChild from './add-child'; 11 | import AddBrother from './add-brother'; 12 | import Edit from './edit'; 13 | import Delete from './delete'; 14 | import Target from './target'; 15 | import { isMobile } from '../../helper'; 16 | 17 | class Toolbar { 18 | private readonly undo: Undo; 19 | private readonly redo: Redo; 20 | private readonly addChildNode: AddChild; 21 | private readonly addBrotherNode: AddBrother; 22 | private readonly edit: Edit; 23 | private readonly delete: Delete; 24 | private readonly target: Target; 25 | 26 | public constructor({ 27 | paperWrapper, 28 | toolOperation, 29 | selection, 30 | dataHandler, 31 | textEditor, 32 | viewportInteraction, 33 | }: { 34 | toolOperation: ToolOperation; 35 | selection: Selection; 36 | dataHandler: DataHandler; 37 | textEditor: TextEditor; 38 | paperWrapper: PaperWrapper; 39 | viewportInteraction: ViewportInteraction; 40 | }) { 41 | this.undo = new Undo({ toolOperation, dataHandler }); 42 | this.redo = new Redo({ toolOperation, dataHandler }); 43 | this.addChildNode = new AddChild({ toolOperation, selection }); 44 | this.addBrotherNode = new AddBrother({ toolOperation, selection }); 45 | this.edit = new Edit({ textEditor, selection, }); 46 | this.delete = new Delete({ toolOperation, selection }); 47 | this.target = new Target({ viewportInteraction }); 48 | 49 | const emptyEl = h('div'); 50 | 51 | const items = [ 52 | this.undo.el, 53 | this.redo.el, 54 | this.buildDivider(), 55 | this.addChildNode.el, 56 | this.addBrotherNode.el, 57 | this.buildDivider(), 58 | isMobile ? emptyEl : this.edit.el, 59 | this.delete.el, 60 | this.buildDivider(), 61 | this.target.el, 62 | ]; 63 | 64 | const wrapperDom = paperWrapper.getWrapperDom(); 65 | 66 | const el = h('div', 'toolbar'); 67 | 68 | // render toolbar 69 | items.forEach((item) => { 70 | el.setChildren(item); 71 | }); 72 | 73 | wrapperDom.appendChild(el.getDom()); 74 | 75 | // change toolbar state when select change 76 | selection.on('select', () => { 77 | this.setState(); 78 | }); 79 | 80 | // change toolbar state when data change 81 | dataHandler.on('data', () => { 82 | setTimeout(() => { 83 | this.setState(); 84 | }, 0); 85 | }); 86 | } 87 | 88 | private buildDivider(): MElement { 89 | return h('div', 'toolbar-divider'); 90 | } 91 | 92 | private setState(): void { 93 | this.undo.setState(); 94 | this.redo.setState(); 95 | this.addChildNode.setState(); 96 | this.addBrotherNode.setState(); 97 | this.edit.setState(); 98 | this.delete.setState(); 99 | } 100 | } 101 | 102 | export default Toolbar; 103 | -------------------------------------------------------------------------------- /apps/core/src/component/toolbar/undo.ts: -------------------------------------------------------------------------------- 1 | import DataHandler from '../../data/data-handler'; 2 | import ToolOperation from '../../tool-operation'; 3 | import { MElement } from '../m-element'; 4 | import { createToolbarItem } from './create-toolbar-item'; 5 | 6 | class Undo { 7 | public readonly el: MElement; 8 | public readonly btnEl: MElement; 9 | private readonly toolOperation: ToolOperation; 10 | private readonly dataHandler: DataHandler; 11 | constructor({ 12 | dataHandler, 13 | toolOperation, 14 | }: { 15 | dataHandler: DataHandler; 16 | toolOperation: ToolOperation; 17 | }) { 18 | this.dataHandler = dataHandler; 19 | this.toolOperation = toolOperation; 20 | const elements = this.element(); 21 | this.el = elements.el; 22 | this.btnEl = elements.btnEl; 23 | } 24 | 25 | public setState(): void { 26 | if (this.dataHandler.canUndo()) { 27 | this.btnEl.removeClass('disabled'); 28 | } else { 29 | this.btnEl.addClass('disabled'); 30 | } 31 | } 32 | 33 | private element(): { 34 | el: MElement; 35 | btnEl: MElement; 36 | } { 37 | const { 38 | el, 39 | btnEl, 40 | } = createToolbarItem({ 41 | iconName: 'undo', 42 | tipLabel: 'Undo', 43 | }); 44 | 45 | btnEl.addEventListener('click', () => { 46 | this.toolOperation.undo(); 47 | }, false); 48 | 49 | return { 50 | el, 51 | btnEl, 52 | }; 53 | } 54 | } 55 | 56 | export default Undo; 57 | -------------------------------------------------------------------------------- /apps/core/src/component/viewport-scale/viewport-scale.ts: -------------------------------------------------------------------------------- 1 | import PaperWrapper from '../../paper-wrapper'; 2 | import Viewport from '../../viewport'; 3 | import { h, MElement } from '../m-element'; 4 | 5 | const zoomSpeed = 0.25; 6 | 7 | // viewport scale component on bottom right corner 8 | class ViewportScale { 9 | private readonly viewport: Viewport; 10 | private readonly el: MElement; 11 | private readonly scaleLabelEl: MElement; 12 | public constructor({ 13 | paperWrapper, 14 | viewport, 15 | }: { 16 | paperWrapper: PaperWrapper; 17 | viewport: Viewport; 18 | }) { 19 | this.viewport = viewport; 20 | const elements = this.element(); 21 | this.el = elements.el; 22 | this.scaleLabelEl = elements.scaleLabelEl; 23 | 24 | const wrapperDom = paperWrapper.getWrapperDom(); 25 | wrapperDom.appendChild(this.el.getDom()); 26 | 27 | viewport.on('changeScale', (scale: number) => { 28 | this.scaleLabelEl.setHtml(this.getScalePercent(scale)); 29 | }); 30 | } 31 | 32 | private element(): { 33 | el: MElement, 34 | scaleLabelEl: MElement; 35 | } { 36 | const scale = this.viewport.getScale(); 37 | const zoomOutEl = h('div', 'scale-btn').setChild( 38 | h('div', 'scale-btn-icon zoom-out') 39 | ); 40 | 41 | const zoomInEl = h('div', 'scale-btn').setChild( 42 | h('div', 'scale-btn-icon zoom-in') 43 | ); 44 | 45 | const scaleLabelEl = h('div', 'scale-label').setChild(this.getScalePercent(scale)) 46 | 47 | const el = h('div', 'viewport-scale-controller').setChildren( 48 | zoomOutEl, 49 | scaleLabelEl, 50 | zoomInEl, 51 | ); 52 | 53 | zoomOutEl.addEventListener('click', () => { 54 | this.viewport.addScale(-zoomSpeed); 55 | }, false); 56 | 57 | zoomInEl.addEventListener('click', () => { 58 | this.viewport.addScale(zoomSpeed); 59 | }, false); 60 | 61 | // click number percent to restore to 100% 62 | scaleLabelEl.addEventListener('click', () => { 63 | this.viewport.setScale(1); 64 | }, false); 65 | 66 | return { 67 | el, 68 | scaleLabelEl, 69 | }; 70 | } 71 | 72 | private getScalePercent(scale: number): string { 73 | return `${Math.floor(scale * 100)}%` 74 | } 75 | } 76 | 77 | export default ViewportScale; 78 | -------------------------------------------------------------------------------- /apps/core/src/config.ts: -------------------------------------------------------------------------------- 1 | interface Config { 2 | isDebug: boolean; 3 | } 4 | 5 | export let config: Config = { 6 | isDebug: false, 7 | }; 8 | 9 | export const setConfig = (newConfig: Partial): void => { 10 | config = { 11 | ...config, 12 | ...newConfig, 13 | }; 14 | }; 15 | 16 | export const getConfig = (): Config => { 17 | return config; 18 | }; 19 | -------------------------------------------------------------------------------- /apps/core/src/data/add-handler.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | import { Direction } from '../types'; 3 | import { DepthType } from '../helper'; 4 | import { updateNodeDataMap, getFatherDatas } from './data-helper'; 5 | import type { NodeData } from '../types'; 6 | 7 | const firstLevetNodeName = 'Main Topic'; 8 | const grandchildNodeName = 'Subtopic'; 9 | 10 | class AddHandler { 11 | public constructor( 12 | private readonly ydoc: Y.Doc, 13 | private readonly nodeDataMap: Y.Map, 14 | ) { } 15 | 16 | public addChildNode(selectionId: string, depth: number, newId: string): void { 17 | const selectionData = this.nodeDataMap.get(selectionId); 18 | if (!selectionData) return; 19 | 20 | // If node is root, then add node equally to each direction 21 | let direction = selectionData.direction; 22 | if (selectionData.isRoot) { 23 | const directionCounts = selectionData.children?.reduce((counts: number[], childId) => { 24 | const childData = this.nodeDataMap.get(childId); 25 | if (childData?.direction === Direction.LEFT) { 26 | counts[0] += 1; 27 | } else { 28 | counts[1] += 1; 29 | } 30 | return counts; 31 | }, [0, 0]); 32 | direction = directionCounts[1] > directionCounts[0] ? Direction.LEFT : Direction.RIGHT; 33 | } 34 | 35 | this.ydoc.transact(() => { 36 | this.nodeDataMap.set(newId, { 37 | label: depth === DepthType.firstLevel ? firstLevetNodeName : grandchildNodeName, 38 | direction, 39 | children: [], 40 | }); 41 | 42 | const selectionChildren = this.nodeDataMap.get(selectionId)!.children; 43 | selectionChildren.push(newId); 44 | updateNodeDataMap(this.nodeDataMap, selectionId, { 45 | children: selectionChildren, 46 | isExpand: true, 47 | }); 48 | }); 49 | 50 | } 51 | 52 | public addBrotherNode(selectionId: string, depth: number, newId: string): void { 53 | const selectionData = this.nodeDataMap.get(selectionId); 54 | if (!selectionData) return; 55 | 56 | if (selectionData.isRoot) { 57 | return this.addChildNode(selectionId, depth, newId); 58 | } 59 | 60 | this.ydoc.transact(() => { 61 | this.nodeDataMap.set(newId, { 62 | label: depth === DepthType.firstLevel ? firstLevetNodeName : grandchildNodeName, 63 | direction: selectionData.direction, 64 | children: [], 65 | }); 66 | 67 | const [fatherId, fatherData] = getFatherDatas(this.nodeDataMap, selectionId); 68 | const brothers = fatherData.children; 69 | brothers.push(newId); 70 | updateNodeDataMap(this.nodeDataMap, fatherId, { 71 | children: brothers, 72 | isExpand: true, 73 | }); 74 | }); 75 | } 76 | } 77 | 78 | export default AddHandler; 79 | -------------------------------------------------------------------------------- /apps/core/src/data/change-father-handler.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | import { updateNodeDataMap, getFatherDatas } from './data-helper'; 3 | import { Direction } from '../types'; 4 | import type { NodeData } from '../types'; 5 | 6 | class ChangeFatherHandler { 7 | public constructor( 8 | private readonly ydoc: Y.Doc, 9 | private readonly nodeDataMap: Y.Map, 10 | ) { } 11 | 12 | public changeFather({ 13 | selectionId, newFatherId, direction, childIndex, 14 | }: { 15 | selectionId: string; 16 | newFatherId: string; 17 | direction: Direction; 18 | childIndex?: number; 19 | }): void { 20 | this.ydoc.transact(() => { 21 | // 删除与原父节点的关系 22 | const [selectionFatherId, selectionFather] = getFatherDatas(this.nodeDataMap, selectionId); 23 | const selectionBrothers = selectionFather.children; 24 | let selectionIndex = selectionBrothers.findIndex((brotherId) => brotherId === selectionId); 25 | selectionIndex = selectionIndex === undefined ? -1 : selectionIndex; 26 | if (selectionIndex > -1) { 27 | selectionBrothers.splice(selectionIndex, 1); 28 | updateNodeDataMap(this.nodeDataMap, selectionFatherId, { 29 | children: selectionBrothers, 30 | }); 31 | } 32 | 33 | // 建立与新父节点的关系 34 | const newFatherData = this.nodeDataMap.get(newFatherId)!; 35 | const newBrothers = newFatherData.children; 36 | 37 | const newChildIndex = childIndex === undefined ? newBrothers.length : childIndex; 38 | if (newFatherData.isRoot) { 39 | this.spliceChildInRoot({ 40 | children: newBrothers, 41 | start: newChildIndex, 42 | childId: selectionId, 43 | direction, 44 | }); 45 | } else { 46 | newBrothers.splice(newChildIndex, 0, selectionId); 47 | } 48 | 49 | updateNodeDataMap(this.nodeDataMap, newFatherId, { 50 | children: newBrothers, 51 | isExpand: true, 52 | }); 53 | 54 | // 改变方向 55 | this.changeDirection(selectionId, direction); 56 | }); 57 | } 58 | 59 | private spliceChildInRoot({ 60 | children, start, childId, direction, 61 | }: { 62 | children: string[]; 63 | start: number; 64 | childId: string; 65 | direction: Direction; 66 | }): void { 67 | let directionStart = -1; 68 | let directionIndex = 0; 69 | let i = 0; 70 | 71 | for (i = 0; i < children.length; i++) { 72 | const childData = this.nodeDataMap.get(children[i]); 73 | if (childData?.direction !== direction) { 74 | continue; 75 | } 76 | if (start === directionIndex) { 77 | directionStart = i; 78 | break; 79 | } 80 | directionIndex++; 81 | } 82 | 83 | if (directionStart === -1) { 84 | directionStart = i; 85 | } 86 | 87 | children.splice(directionStart, 0, childId); 88 | } 89 | 90 | private changeDirection(id: string, direction: Direction): void { 91 | const nodeData = this.nodeDataMap.get(id); 92 | if (!nodeData) return; 93 | 94 | updateNodeDataMap(this.nodeDataMap, id, { 95 | direction, 96 | }); 97 | 98 | nodeData.children.forEach((childId) => this.changeDirection(childId, direction)); 99 | } 100 | } 101 | 102 | export default ChangeFatherHandler; 103 | -------------------------------------------------------------------------------- /apps/core/src/data/data-handler.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | import EventEmitter from 'eventemitter3'; 3 | import { Direction } from '../types'; 4 | import UndoHandler from './undo-handler'; 5 | import AddHandler from './add-handler'; 6 | import RemoveHandler from './remove-handler'; 7 | import ChangeFatherHandler from './change-father-handler'; 8 | import { updateNodeDataMap } from './data-helper'; 9 | import { generateId } from '../helper'; 10 | import type Selection from '../selection/selection'; 11 | import type { NodeData, NodeDataMap } from '../types'; 12 | 13 | export const getInitialData = (data?: NodeDataMap): NodeDataMap => { 14 | const initialData = data || { 15 | root: { 16 | children: [], 17 | label: 'Central Topic', 18 | direction: Direction.NONE, 19 | isRoot: true, 20 | }, 21 | } 22 | 23 | Object.keys(initialData).forEach((id) => { 24 | const item = initialData[id] 25 | const isExpand = item.isExpand 26 | item.isExpand = isExpand === undefined ? true : isExpand; 27 | }); 28 | 29 | return initialData; 30 | } 31 | 32 | export interface DataHandlerEventMap { 33 | data: (result: { data: NodeDataMap; preSelectIds: string[] }) => void; 34 | } 35 | 36 | class DataHandler { 37 | private readonly ydoc: Y.Doc; 38 | private readonly nodeDataMap: Y.Map; 39 | private readonly eventEmitter: EventEmitter; 40 | private readonly undoHandler: UndoHandler; 41 | private readonly addHandler: AddHandler; 42 | private readonly removeHandler: RemoveHandler; 43 | private readonly changeFatherHandler: ChangeFatherHandler; 44 | private preSelectIds: string[] = []; 45 | 46 | public constructor( 47 | private readonly selection: Selection, 48 | initialData?: Record, 49 | ydoc?: Y.Doc, 50 | ) { 51 | this.eventEmitter = new EventEmitter(); 52 | this.ydoc = ydoc || new Y.Doc(); 53 | 54 | this.nodeDataMap = this.ydoc.getMap('mindmaptree node data map'); 55 | 56 | if (initialData) { 57 | Object.keys(initialData).forEach((id) => { 58 | const nodeData = initialData[id]; 59 | this.nodeDataMap.set(id, nodeData); 60 | }); 61 | } 62 | 63 | this.undoHandler = new UndoHandler(this.nodeDataMap); 64 | this.addHandler = new AddHandler(this.ydoc, this.nodeDataMap); 65 | this.removeHandler = new RemoveHandler(this.ydoc, this.nodeDataMap); 66 | this.changeFatherHandler = new ChangeFatherHandler(this.ydoc, this.nodeDataMap); 67 | 68 | this.nodeDataMap.observe((event) => { 69 | this.eventEmitter.emit('data', { 70 | data: event.target.toJSON(), 71 | preSelectIds: [...this.preSelectIds], 72 | }); 73 | this.preSelectIds = []; 74 | }); 75 | } 76 | 77 | public on>( 78 | eventName: T, 79 | callback: EventEmitter.EventListener 80 | ): void { 81 | this.eventEmitter.on(eventName, callback); 82 | } 83 | 84 | public undo(): void { 85 | this.undoHandler.undo(); 86 | } 87 | 88 | public redo(): void { 89 | this.undoHandler.redo(); 90 | } 91 | 92 | public canUndo(): boolean { 93 | return this.undoHandler.canUndo(); 94 | } 95 | 96 | public canRedo(): boolean { 97 | return this.undoHandler.canRedo(); 98 | } 99 | 100 | public addChildNode(selectionId: string, depth: number): void { 101 | const newId = generateId(); 102 | this.preSelectIds = [newId]; 103 | this.addHandler.addChildNode(selectionId, depth, newId); 104 | } 105 | 106 | public addBrotherNode(selectionId: string, depth: number): void { 107 | const newId = generateId(); 108 | this.preSelectIds = [newId]; 109 | this.addHandler.addBrotherNode(selectionId, depth, newId); 110 | } 111 | 112 | public removeNode(selecNodeIds: string[]): void { 113 | const removeNextNode = this.selection.getRemoveNextNode(); 114 | if (removeNextNode) { 115 | this.preSelectIds = [removeNextNode.id]; 116 | } 117 | 118 | this.removeHandler.removeNode(selecNodeIds); 119 | } 120 | 121 | public changeFather(params: { 122 | selectionId: string; 123 | newFatherId: string; 124 | direction: Direction; 125 | childIndex?: number; 126 | }): void { 127 | this.preSelectIds = [params.selectionId]; 128 | this.changeFatherHandler.changeFather(params); 129 | } 130 | 131 | public update(id: string, params: { 132 | label?: string; 133 | isExpand?: boolean; 134 | }): void { 135 | updateNodeDataMap(this.nodeDataMap, id, params); 136 | } 137 | } 138 | 139 | export default DataHandler; 140 | -------------------------------------------------------------------------------- /apps/core/src/data/data-helper.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | import type { NodeData } from '../types'; 3 | 4 | export const updateNodeDataMap = (nodeDataMap: Y.Map, id: string, data: Partial): void => { 5 | const originData = nodeDataMap.get(id); 6 | if (!originData) return; 7 | 8 | const newData = { 9 | ...originData, 10 | ...data, 11 | }; 12 | nodeDataMap.set(id, newData); 13 | } 14 | 15 | // todo 获取root,能不能不通过遍历? 16 | export const getRootData = (nodeDataMap: Y.Map): NodeData => { 17 | let rootData: NodeData | undefined; 18 | const values = nodeDataMap.values() as Iterable; 19 | for (let value of values) { 20 | if (value.isRoot) { 21 | rootData = value; 22 | } 23 | } 24 | return rootData!; 25 | } 26 | 27 | // todo 获取father,能不能不通过遍历? 28 | export const getFatherDatas = (nodeDataMap: Y.Map, id: string): [string, NodeData] => { 29 | const entries = nodeDataMap.entries() as Iterable<[string, NodeData]>; 30 | let fatherDatas: [string, NodeData] | undefined; 31 | for (let entry of entries) { 32 | if (entry[1].children.includes(id)) { 33 | fatherDatas = entry; 34 | } 35 | } 36 | return fatherDatas!; 37 | } 38 | 39 | -------------------------------------------------------------------------------- /apps/core/src/data/remove-handler.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | import { getRootData } from './data-helper'; 3 | import type { NodeData } from '../types'; 4 | 5 | class RemoveHandler { 6 | public constructor( 7 | private readonly ydoc: Y.Doc, 8 | private readonly nodeDataMap: Y.Map, 9 | ) { } 10 | 11 | public removeNode(selecNodeIds: string[]): void { 12 | const rootData = getRootData(this.nodeDataMap); 13 | const topNodeIds = this.getTopNodeIdsInner(['', rootData], selecNodeIds); 14 | 15 | if (topNodeIds.length === 0) return; 16 | 17 | this.ydoc.transact(() => { 18 | topNodeIds.forEach((id) => this.removeNodeDataInner(id)); 19 | }); 20 | } 21 | 22 | private getTopNodeIdsInner([currentId, currentData]: [string, NodeData], selecNodeIds: string[]): string[] { 23 | if (!currentData) return []; 24 | 25 | if (!currentData.isRoot && selecNodeIds.includes(currentId)) { 26 | return [currentId]; 27 | } 28 | 29 | let topNodeIds: string[] = []; 30 | 31 | currentData.children?.forEach((childId) => { 32 | const childTopIds = this.getTopNodeIdsInner([childId, this.nodeDataMap.get(childId)!], selecNodeIds); 33 | if (childTopIds.length > 0) { 34 | topNodeIds = topNodeIds.concat(childTopIds); 35 | } 36 | }); 37 | 38 | return topNodeIds; 39 | } 40 | 41 | private removeNodeDataInner(removeId: string): void { 42 | const removeData = this.nodeDataMap.get(removeId); 43 | 44 | if (!removeData) return; 45 | removeData?.children.forEach((childId) => { 46 | this.removeNodeDataInner(childId); 47 | }); 48 | 49 | this.nodeDataMap.delete(removeId); 50 | } 51 | } 52 | 53 | export default RemoveHandler; 54 | -------------------------------------------------------------------------------- /apps/core/src/data/undo-handler.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | import { NodeData } from '../types'; 3 | 4 | class UndoHandler { 5 | private readonly undoManager: Y.UndoManager; 6 | 7 | public constructor(nodeDataMap: Y.Map) { 8 | this.undoManager = new Y.UndoManager(nodeDataMap); 9 | } 10 | 11 | public undo(): void { 12 | this.undoManager.undo(); 13 | } 14 | 15 | public redo(): void { 16 | this.undoManager.redo(); 17 | } 18 | 19 | public canUndo(): boolean { 20 | return this.undoManager.undoStack.length > 0; 21 | } 22 | 23 | public canRedo(): boolean { 24 | return this.undoManager.redoStack.length > 0; 25 | } 26 | } 27 | 28 | export default UndoHandler; 29 | -------------------------------------------------------------------------------- /apps/core/src/drag/drag-area.ts: -------------------------------------------------------------------------------- 1 | import Node from '../node/node'; 2 | import { Direction } from '../types'; 3 | import { getNodeYGap } from '../shape/gap'; 4 | import DragTemp from './drag-temp'; 5 | import DrawDragArea from './draw-drag-area'; 6 | import type { RaphaelPaper } from 'raphael'; 7 | 8 | interface AreaBox { 9 | x: number; 10 | y: number; 11 | x2: number; 12 | y2: number; 13 | } 14 | 15 | export interface Area { 16 | node: Node; 17 | areaBox: AreaBox; 18 | direction: Direction; 19 | childrenYList: number[]; 20 | } 21 | 22 | interface HitAreaData { 23 | father: Node; 24 | childIndex: number; 25 | isOwnArea: boolean; 26 | direction: Direction; 27 | } 28 | 29 | export type HitArea = HitAreaData | null; 30 | 31 | const rootYPatchGap = 80; 32 | const leafAreaWidth = 100; 33 | 34 | class DragArea { 35 | private readonly root: Node; 36 | private readonly drawDragArea: DrawDragArea | null = null; 37 | private readonly dragTemp: DragTemp; 38 | private areaList: Area[] = []; 39 | private hitArea: HitArea = null; 40 | public constructor( 41 | paper: RaphaelPaper, 42 | private readonly node: Node, 43 | ) { 44 | this.dragTemp = new DragTemp(paper, node); 45 | this.drawDragArea = new DrawDragArea(paper); 46 | this.root = node.getRoot()!; 47 | } 48 | 49 | public drawTemp(x: number, y: number): void { 50 | const newHitArea = this.getHitArea(x, y); 51 | if (!this.isSameHitArea(this.hitArea, newHitArea)) { 52 | this.dragTemp.draw(newHitArea); 53 | this.hitArea = newHitArea; 54 | } 55 | } 56 | 57 | public init(): void { 58 | this.areaList = this.getAreaList(this.root); 59 | this.drawDragArea?.draw(this.areaList); 60 | } 61 | 62 | public clear(): void { 63 | this.areaList = []; 64 | this.drawDragArea?.clear(); 65 | this.dragTemp.clear(); 66 | } 67 | 68 | public getCurrentHitArea(): HitArea { 69 | return this.hitArea; 70 | } 71 | 72 | private getHitArea(x: number, y: number): HitArea { 73 | for (let i = 0; i < this.areaList.length; i++) { 74 | const { node, areaBox, childrenYList, direction } = this.areaList[i]; 75 | 76 | if (x >= areaBox.x && x <= areaBox.x2 && y >= areaBox.y && y <= areaBox.y2) { 77 | if (!childrenYList || childrenYList.length === 0 || node.isExpand === false) { 78 | return { 79 | father: node, 80 | childIndex: 0, 81 | isOwnArea: this.isOwnArea(node, 0, direction), 82 | direction, 83 | } 84 | } 85 | for (let i = 0; i < childrenYList.length - 1; i++) { 86 | if (y >= childrenYList[i] && y < childrenYList[i + 1]) { 87 | return { 88 | father: node, 89 | childIndex: i, 90 | isOwnArea: this.isOwnArea(node, i, direction), 91 | direction, 92 | } 93 | } 94 | } 95 | } 96 | } 97 | 98 | return null; 99 | } 100 | 101 | // Check if hit the dragging node or not 102 | private isOwnArea(father: Node, childIndex: number, direction: Direction): boolean { 103 | const brothers = this.node.father?.getDirectionChildren(direction); 104 | const currentNodeIndex = brothers?.findIndex((child) => child.id === this.node.id); 105 | 106 | return father?.id === this.node.father?.id 107 | && currentNodeIndex !== undefined && currentNodeIndex > -1 108 | && (childIndex === currentNodeIndex || childIndex === currentNodeIndex + 1) 109 | } 110 | 111 | private isSameHitArea(hitArea1: HitArea, hitArea2: HitArea) { 112 | return ( 113 | hitArea1?.father.id === hitArea2?.father.id 114 | && hitArea1?.childIndex === hitArea2?.childIndex 115 | && hitArea1?.direction === hitArea2?.direction 116 | ); 117 | } 118 | 119 | // Sequence traversal 120 | private getAreaList(root: Node): Area[] { 121 | const areaList: Area[] = []; 122 | 123 | const queue: { 124 | node: Node; 125 | direction: Direction 126 | }[] = [{ 127 | node: root, 128 | direction: Direction.RIGHT 129 | }, { 130 | node: root, 131 | direction: Direction.LEFT 132 | }]; 133 | 134 | while (queue.length > 0) { 135 | const queueLength = queue.length; 136 | 137 | for (let i = 0; i < queueLength; i++) { 138 | const { 139 | node: current, 140 | direction, 141 | } = queue.shift()!; 142 | 143 | if (this.node.id === current.id || current.father?.isExpand === false) { 144 | continue; 145 | } 146 | 147 | const area = this.getArea(current, direction); 148 | areaList.unshift(area); 149 | 150 | current.getDirectionChildren(direction).forEach((child) => queue.push({ 151 | node: child, 152 | direction: direction, 153 | })); 154 | } 155 | } 156 | 157 | return areaList; 158 | } 159 | 160 | private getArea(node: Node, direction: Direction): Area { 161 | const areaBox = { x: 0, y: 0, x2: 0, y2: 0 }; 162 | const nodeBBox = node.getBBox(); 163 | const children = node.getDirectionChildren(direction); 164 | 165 | let startY = 0; 166 | let endY = 0; 167 | 168 | if (children.length === 0 || !node.isExpand) { 169 | startY = nodeBBox.y; 170 | endY = nodeBBox.y2; 171 | } else { 172 | const firstChild = children[0]; 173 | const lastChild = children[children.length - 1]; 174 | 175 | startY = firstChild.getBBox().y; 176 | endY = lastChild.getBBox().y2; 177 | } 178 | 179 | const yGap = this.getNodeYGap(node); 180 | startY -= yGap; 181 | endY += yGap; 182 | 183 | const childBoundaryX = this.getChildBoundaryX(node, direction); 184 | 185 | areaBox.y = startY; 186 | areaBox.y2 = endY; 187 | 188 | if (node.isRoot()) { 189 | areaBox.x = direction === Direction.RIGHT ? nodeBBox.cx : childBoundaryX; 190 | areaBox.x2 = direction === Direction.RIGHT ? childBoundaryX : nodeBBox.cx; 191 | } else { 192 | areaBox.x = direction === Direction.RIGHT ? nodeBBox.x2 : childBoundaryX; 193 | areaBox.x2 = direction === Direction.RIGHT ? childBoundaryX : nodeBBox.x; 194 | } 195 | 196 | const childrenYList = this.getChildrenYList(children, areaBox); 197 | 198 | return { 199 | node, 200 | areaBox: areaBox, 201 | direction, 202 | childrenYList, 203 | } 204 | } 205 | 206 | private getChildrenYList(children: Node[], areaBox: AreaBox): number[] { 207 | const childrenYList = [areaBox.y]; 208 | 209 | children.forEach((child) => { 210 | const childBBox = child.getBBox(); 211 | childrenYList.push(childBBox.cy); 212 | }); 213 | 214 | childrenYList.push(areaBox.y2); 215 | 216 | return childrenYList; 217 | } 218 | 219 | private getNodeYGap(node: Node): number { 220 | const patchY = node.isRoot() ? rootYPatchGap : 0; 221 | return (getNodeYGap(node.depth) + patchY) / 2; 222 | } 223 | 224 | private getChildBoundaryX(node: Node, direction: Direction): number { 225 | const children = node.getDirectionChildren(direction); 226 | 227 | if (children.length === 0 || node.isExpand === false) { 228 | const nodeBBox = node.getBBox(); 229 | return direction === Direction.RIGHT ? nodeBBox.x2 + leafAreaWidth : nodeBBox.x - leafAreaWidth; 230 | } 231 | 232 | let maxWidthChild = children[0]; 233 | let max = maxWidthChild.getBBox().width; 234 | for (let i = 1; i < children.length; i++) { 235 | const child = children[i]; 236 | const childBBox = child.getBBox(); 237 | if (childBBox.width > max) { 238 | max = childBBox.width; 239 | maxWidthChild = child; 240 | } 241 | } 242 | 243 | const maxWidthChildBBox = maxWidthChild.getBBox(); 244 | 245 | return direction === Direction.RIGHT ? maxWidthChildBBox.x2 : maxWidthChildBBox.x; 246 | } 247 | } 248 | 249 | export default DragArea; 250 | -------------------------------------------------------------------------------- /apps/core/src/drag/drag-cloned-node.ts: -------------------------------------------------------------------------------- 1 | import NodeShape from '../shape/node-shape'; 2 | 3 | class DragClonedNode { 4 | private clonedNodeShape: NodeShape | null = null; 5 | public constructor(private readonly originNodeShape: NodeShape) { } 6 | 7 | public init(): void { 8 | this.clonedNodeShape = this.originNodeShape.clone(); 9 | this.clonedNodeShape.setStyle('disable'); 10 | } 11 | 12 | public translate(dx: number, dy: number): void { 13 | this.clonedNodeShape?.translate(dx, dy); 14 | } 15 | 16 | public clear(): void { 17 | this.clonedNodeShape?.remove(); 18 | } 19 | } 20 | 21 | export default DragClonedNode; 22 | -------------------------------------------------------------------------------- /apps/core/src/drag/drag-temp.ts: -------------------------------------------------------------------------------- 1 | 2 | import Node from '../node/node'; 3 | import DragTempNodeShape from "../shape/drag-temp-node-shape"; 4 | import type { RaphaelPaper, RaphaelAxisAlignedBoundingBox } from "raphael"; 5 | import type { HitArea } from "./drag-area"; 6 | 7 | class DragTemp { 8 | private shape: DragTempNodeShape | null = null; 9 | public constructor( 10 | private readonly paper: RaphaelPaper, 11 | private readonly node: Node, 12 | ) { } 13 | 14 | public draw(hitArea: HitArea): void { 15 | this.shape?.remove(); 16 | 17 | if (hitArea === null) { 18 | this.shape = null; 19 | } else { 20 | const { father, childIndex, isOwnArea, direction } = hitArea; 21 | 22 | const dragNodeBBox = this.node.getBBox(); 23 | const fatherBBox = father.getBBox(); 24 | if (isOwnArea) { 25 | this.shape = new DragTempNodeShape({ 26 | paper: this.paper, 27 | sourceBBox: fatherBBox, 28 | targetBBox1: dragNodeBBox, 29 | targetBBox2: dragNodeBBox, 30 | targetDepth: father.depth + 1, 31 | direction, 32 | }) 33 | } else { 34 | const children = father.getDirectionChildren(direction); 35 | let targetBBox1: RaphaelAxisAlignedBoundingBox | null = children[childIndex - 1]?.getBBox() || null; 36 | let targetBBox2: RaphaelAxisAlignedBoundingBox | null = children[childIndex]?.getBBox() || null; 37 | 38 | if (!father.isExpand) { 39 | targetBBox1 = null; 40 | targetBBox2 = null; 41 | } 42 | 43 | this.shape = new DragTempNodeShape({ 44 | paper: this.paper, 45 | sourceBBox: fatherBBox, 46 | targetBBox1, 47 | targetBBox2, 48 | targetDepth: father.depth + 1, 49 | direction, 50 | }); 51 | } 52 | if (father.isRoot()) { 53 | father.nodeShapeToFront(); 54 | } 55 | } 56 | } 57 | 58 | public clear(): void { 59 | this.shape?.remove(); 60 | this.shape = null; 61 | } 62 | } 63 | 64 | export default DragTemp; 65 | -------------------------------------------------------------------------------- /apps/core/src/drag/drag.ts: -------------------------------------------------------------------------------- 1 | 2 | import EventEmitter from 'eventemitter3'; 3 | import Node from '../node/node'; 4 | import NodeShape from '../shape/node-shape'; 5 | import Viewport from '../viewport'; 6 | import DragArea, { HitArea } from './drag-area'; 7 | import DragClonedNode from './drag-cloned-node'; 8 | import { isMobile } from '../helper'; 9 | import type { RaphaelPaper } from 'raphael'; 10 | import type { TraverseFunc } from '../node/node'; 11 | import type { StyleType } from '../shape/common/node-shape-style'; 12 | 13 | const validDiff = 2; 14 | 15 | export interface DragEventMap { 16 | dragEnd: (hitArea: HitArea) => void; 17 | } 18 | 19 | // Drag node to change father, except root node. 20 | class Drag { 21 | private readonly node: Node; 22 | private readonly viewport: Viewport; 23 | private readonly paper: RaphaelPaper; 24 | private readonly dragArea: DragArea; 25 | private readonly dragClonedNode: DragClonedNode; 26 | private readonly eventEmitter: EventEmitter; 27 | private hitFather: Node | null = null; 28 | private traverse: TraverseFunc; 29 | private isStart: boolean = false; 30 | private isMoveInited: boolean = false; 31 | private startX: number = 0; 32 | private startY: number = 0; 33 | private lastDx: number = 0; 34 | private lastDy: number = 0; 35 | private styleTypeMap: Record = {}; 36 | 37 | public constructor({ 38 | paper, 39 | node, 40 | nodeShape, 41 | viewport, 42 | traverse, 43 | }: { 44 | paper: RaphaelPaper; 45 | node: Node; 46 | nodeShape: NodeShape; 47 | viewport: Viewport; 48 | traverse: TraverseFunc; 49 | }) { 50 | this.paper = paper; 51 | this.node = node; 52 | this.viewport = viewport; 53 | this.traverse = traverse; 54 | 55 | this.dragArea = new DragArea(this.paper, node); 56 | this.dragClonedNode = new DragClonedNode(nodeShape); 57 | this.eventEmitter = new EventEmitter(); 58 | 59 | if (!isMobile) { 60 | // init drag event 61 | nodeShape.on('drag', this.move, this.start, this.end); 62 | } 63 | } 64 | 65 | public clear(): void { 66 | this.eventEmitter.removeAllListeners(); 67 | } 68 | 69 | public on>( 70 | eventName: T, 71 | callback: EventEmitter.EventListener 72 | ) { 73 | this.eventEmitter.on(eventName, callback); 74 | } 75 | 76 | private start = (clientX: number, clientY: number, event: MouseEvent): void => { 77 | event.stopPropagation(); 78 | 79 | this.isStart = true; 80 | 81 | const viewportPosition = this.viewport.getViewportPosition(clientX, clientY); 82 | this.startX = viewportPosition.x; 83 | this.startY = viewportPosition.y; 84 | } 85 | 86 | private move = (originDx: number, originDy: number): void => { 87 | const scale = this.viewport.getScale(); 88 | const dx = originDx / scale; 89 | const dy = originDy / scale; 90 | 91 | if (!this.isStart) return; 92 | 93 | // Start initing when move the valid distance. 94 | if (!this.isMoveInited && (Math.abs(originDx) > validDiff || Math.abs(originDy) > validDiff)) { 95 | this.dragArea.init(); 96 | this.dragClonedNode.init(); 97 | this.dragClonedNode.translate(this.lastDx, this.lastDy); 98 | 99 | this.traverse(this.node, ({ node, nodeShape, expander, edgeShape }) => { 100 | this.styleTypeMap[node.id] = nodeShape.getStyle(); 101 | nodeShape.setStyle('disable'); 102 | expander.setStyle('disable'); 103 | edgeShape?.setStyle('disable'); 104 | }); 105 | 106 | document.body.style.cursor = 'grabbing'; 107 | 108 | this.isMoveInited = true; 109 | } 110 | 111 | if (this.isMoveInited) { 112 | const x = this.startX + dx; 113 | const y = this.startY + dy; 114 | this.dragArea.drawTemp(x, y); 115 | 116 | const offsetX = (dx - this.lastDx); 117 | const offsetY = (dy - this.lastDy); 118 | this.dragClonedNode.translate(offsetX, offsetY); 119 | 120 | const currentHitArea = this.dragArea.getCurrentHitArea(); 121 | if (currentHitArea?.father.id !== this.hitFather?.id) { 122 | currentHitArea?.father.setStyle('overlay'); 123 | this.hitFather?.setStyle('base'); 124 | } 125 | 126 | this.hitFather = currentHitArea?.father || null; 127 | } 128 | 129 | this.lastDx = dx; 130 | this.lastDy = dy; 131 | } 132 | 133 | // clear data when mousedown 134 | private end = (): void => { 135 | this.isStart = false; 136 | this.startX = 0; 137 | this.startY = 0; 138 | 139 | if (!this.isMoveInited) return; 140 | 141 | this.isMoveInited = false; 142 | this.lastDx = 0; 143 | this.lastDy = 0; 144 | 145 | this.hitFather?.setStyle('base'); 146 | this.hitFather = null; 147 | 148 | const hitArea = this.dragArea.getCurrentHitArea(); 149 | this.eventEmitter.emit('dragEnd', hitArea); 150 | 151 | this.dragArea.clear(); 152 | this.dragClonedNode.clear(); 153 | 154 | document.body.style.cursor = 'default'; 155 | 156 | this.traverse(this.node, ({ node, nodeShape, expander, edgeShape }) => { 157 | const curStyleType = this.styleTypeMap[node.id]; 158 | nodeShape.setStyle(curStyleType); 159 | expander.setStyle('base'); 160 | edgeShape?.setStyle('base'); 161 | }); 162 | 163 | this.styleTypeMap = {}; 164 | } 165 | 166 | } 167 | 168 | export default Drag; 169 | -------------------------------------------------------------------------------- /apps/core/src/drag/draw-drag-area.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from '../config'; 2 | import type { RaphaelPaper, RaphaelSet } from 'raphael'; 3 | import type { Area } from './drag-area'; 4 | 5 | // for debugging 6 | class DrawDragArea { 7 | private shapeSet: RaphaelSet | null = null; 8 | public constructor( 9 | private readonly paper: RaphaelPaper, 10 | ) { } 11 | 12 | public draw(areaList: Area[]): void { 13 | const { isDebug } = getConfig(); 14 | if (!isDebug) return; 15 | 16 | this.shapeSet = this.paper.set(); 17 | 18 | areaList.forEach(({ node, areaBox, childrenYList }) => { 19 | let color = 'green'; 20 | if (node.children.length === 0) { 21 | color = 'red'; 22 | } else if (node.depth === 0) { 23 | color = 'orange'; 24 | } 25 | 26 | const { x, y, x2, y2 } = areaBox; 27 | 28 | const rectShape = this.paper.rect(x, y, x2 - x, y2 - y).attr({ 29 | 'stroke-opacity': 0, 30 | 'fill': color, 31 | opacity: 0.2, 32 | }); 33 | 34 | this.shapeSet?.push(rectShape); 35 | 36 | childrenYList?.forEach((y: number) => { 37 | const edgeShape = this.paper.path(`M${x} ${y}L${x2} ${y}`).attr({ 38 | stroke: color, 39 | opacity: 0.6, 40 | }); 41 | this.shapeSet?.push(edgeShape); 42 | }); 43 | 44 | this.shapeSet?.toBack(); 45 | }); 46 | } 47 | 48 | public clear(): void { 49 | const { isDebug } = getConfig(); 50 | if (!isDebug) return; 51 | this.shapeSet?.remove(); 52 | } 53 | } 54 | 55 | export default DrawDragArea; 56 | -------------------------------------------------------------------------------- /apps/core/src/helper.ts: -------------------------------------------------------------------------------- 1 | export enum DepthType { 2 | root = 0, 3 | firstLevel, 4 | grandchild, 5 | } 6 | 7 | export function getDepthType(depth: number): DepthType { 8 | if (depth === 0) { 9 | return DepthType.root; 10 | } else if (depth === 1) { 11 | return DepthType.firstLevel; 12 | } 13 | return DepthType.grandchild; 14 | } 15 | 16 | export function generateId(): string { 17 | function S4(): string { 18 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); 19 | }; 20 | return `${S4()}${S4()}-${S4()}${S4()}${S4()}`; 21 | } 22 | 23 | export const mobileCheck = function() { 24 | let check = false; 25 | // @ts-ignore 26 | (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera); 27 | return check; 28 | }; 29 | 30 | export const isMobile = mobileCheck(); 31 | 32 | export const isWindows = (): boolean => { 33 | return navigator.platform.indexOf('Win') > -1 34 | }; 35 | -------------------------------------------------------------------------------- /apps/core/src/img/add-brother.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/img/add-child.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/img/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/img/edit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/img/redo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/img/target.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/img/undo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/img/zoom-in.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/img/zoom-out.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/core/src/index.less: -------------------------------------------------------------------------------- 1 | .mindmap-graph { 2 | width: 100%; 3 | height: 100%; 4 | position: relative; 5 | z-index: 2; 6 | } 7 | 8 | .mindmap-graph-background { 9 | position: absolute; 10 | z-index: 1; 11 | top: 0; 12 | right: 0; 13 | bottom: 0; 14 | left: 0; 15 | background-color: #fff; 16 | } 17 | 18 | .toolbar { 19 | position: absolute; 20 | left: 50%; 21 | top: 20px; 22 | z-index: 1; 23 | transform: translateX(-50%); 24 | padding: 6px 10px; 25 | border-radius: 8px; 26 | height: 40px; 27 | box-shadow: 0 0px 10px 0 rgb(0 0 0 / 5%), inset 0 0 1px 0 rgb(0 0 0 / 20%), 0 12px 40px 0 rgb(0 0 0 / 10%); 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | background-color: #fff; 32 | 33 | .toolbar-item { 34 | position: relative; 35 | margin-left: 12px; 36 | 37 | &:first-child { 38 | margin-left: 0; 39 | } 40 | 41 | .toolbar-btn { 42 | width: 32px; 43 | height: 32px; 44 | display: flex; 45 | align-items: center; 46 | justify-content: center; 47 | border-radius: 4px; 48 | cursor: pointer; 49 | 50 | &.disabled { 51 | pointer-events: none; 52 | opacity: 0.5; 53 | } 54 | 55 | &:hover { 56 | background-color: rgb(231, 231, 231, 0.6); 57 | } 58 | 59 | &:active { 60 | background-color: rgba(204, 209, 209, 0.8); 61 | } 62 | 63 | .toolbar-icon { 64 | width: 23px; 65 | height: 23px; 66 | background-repeat: no-repeat; 67 | background-size: 100% 100%; 68 | } 69 | } 70 | 71 | &:hover .toolbar-tip { 72 | display: block; 73 | } 74 | 75 | .toolbar-tip { 76 | display: none; 77 | font-family: inherit; 78 | position: absolute; 79 | z-index: 1; 80 | bottom: -34px; 81 | left: 50%; 82 | transform: translateX(-50%); 83 | margin-right: -200px; 84 | padding: 5px 10px; 85 | color: #fff; 86 | border-radius: 1px; 87 | background-color: rgba(0, 0, 0, 0.8); 88 | font-size: 12px; 89 | border-radius: 4px; 90 | 91 | &:before { 92 | display: block; 93 | content: ""; 94 | position: absolute; 95 | left: 50%; 96 | top: -6px; 97 | transform: translateX(-50%); 98 | width: 0; 99 | height: 0; 100 | border-bottom: 6px solid rgba(0, 0, 0, 0.8); 101 | border-right: 6px solid transparent; 102 | border-left: 6px solid transparent; 103 | } 104 | } 105 | } 106 | 107 | 108 | .toolbar-divider { 109 | width: 1px; 110 | height: 24px; 111 | background-color: rgba(0, 0, 0, 0.1); 112 | margin-left: 12px; 113 | } 114 | 115 | .undo-icon { 116 | background-image: url('./img/undo.svg'); 117 | } 118 | 119 | .redo-icon { 120 | background-image: url('./img/redo.svg'); 121 | } 122 | 123 | .add-child-icon { 124 | background-image: url('./img/add-child.svg'); 125 | } 126 | 127 | .add-brother-icon { 128 | background-image: url('./img/add-brother.svg'); 129 | } 130 | 131 | .edit-icon { 132 | background-image: url('./img/edit.svg'); 133 | } 134 | 135 | .delete-icon { 136 | background-image: url('./img/delete.svg'); 137 | } 138 | 139 | .target-icon { 140 | background-image: url('./img/target.svg'); 141 | } 142 | } 143 | 144 | .viewport-scale-controller { 145 | position: absolute; 146 | bottom: 20px; 147 | right: 20px; 148 | z-index: 1; 149 | padding: 0 12px; 150 | border-radius: 8px; 151 | box-shadow: 0 0px 10px 0 rgb(0 0 0 / 5%), inset 0 0 1px 0 rgb(0 0 0 / 20%), 0 12px 40px 0 rgb(0 0 0 / 10%); 152 | height: 44px; 153 | display: flex; 154 | align-items: center; 155 | justify-content: center; 156 | user-select: none; 157 | background-color: #fff; 158 | 159 | .scale-label { 160 | font-size: 14px; 161 | font-weight: 500; 162 | line-height: 24px; 163 | width: 44px; 164 | text-align: center; 165 | border-radius: 2px; 166 | cursor: pointer; 167 | 168 | &:hover { 169 | background-color: rgb(231, 231, 231, 0.6); 170 | } 171 | 172 | &:active { 173 | background-color: rgba(204, 209, 209, 0.8); 174 | } 175 | } 176 | 177 | .scale-btn { 178 | padding: 2px; 179 | border-radius: 2px; 180 | cursor: pointer; 181 | 182 | .scale-btn-icon { 183 | width: 15px; 184 | height: 15px; 185 | background-repeat: no-repeat; 186 | background-size: 100% 100%; 187 | } 188 | 189 | &:hover { 190 | background-color: rgb(231, 231, 231, 0.6); 191 | } 192 | 193 | &:active { 194 | background-color: rgba(204, 209, 209, 0.8); 195 | } 196 | } 197 | 198 | .zoom-out { 199 | background-image: url('./img/zoom-out.svg'); 200 | } 201 | 202 | .zoom-in { 203 | background-image: url('./img/zoom-in.svg'); 204 | } 205 | } 206 | 207 | .node-edit-text-wrapper { 208 | position: absolute; 209 | z-index: -9999; 210 | top: 0; 211 | left: 0; 212 | width: 600px; 213 | } 214 | 215 | .node-edit-text { 216 | position: absolute; 217 | top: 0; 218 | left: 50%; 219 | transform: translateX(-50%); 220 | background-color: #fff; 221 | pointer-events: all; 222 | outline: 0; 223 | box-shadow: 0 0 20px rgb(0 0 0 / 50%); 224 | padding: 3px 5px; 225 | max-width: 300px; 226 | width: auto; 227 | font-size: 14px; 228 | line-height: 1.4em; 229 | min-height: 1.4em; 230 | box-sizing: border-box; 231 | overflow: hidden; 232 | word-break: break-all; 233 | word-wrap: break-word; 234 | border: none; 235 | -webkit-user-select: text; 236 | font-size: 16px; 237 | } 238 | -------------------------------------------------------------------------------- /apps/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | import PaperWrapper from './paper-wrapper'; 3 | import Tree from './tree/tree'; 4 | import NodeCreator from './node/node-creator'; 5 | import NodeInteraction from './node-interaction'; 6 | import Viewport from './viewport'; 7 | import ViewportInteraction from './viewport-interaction/viewport-interaction'; 8 | import TextEditor from './text-editor'; 9 | import Selection from './selection/selection'; 10 | import MultiSelect from './selection/multi-select'; 11 | import Keyboard from './keyboard/keyboard'; 12 | import { setConfig } from './config'; 13 | import ToolOperation from './tool-operation'; 14 | import Toolbar from './component/toolbar/toolbar'; 15 | import ViewportScale from './component/viewport-scale/viewport-scale'; 16 | import SelectionBoundaryMove from './selection/selection-boundary-move'; 17 | import DataHandler, { getInitialData } from './data/data-handler'; 18 | import AwarenessHandler from './awareness-handler'; 19 | import type { Awareness } from "y-protocols/awareness"; 20 | import type { DataHandlerEventMap } from './data/data-handler'; 21 | import type { NodeDataMap } from './types'; 22 | import './index.less'; 23 | import './mobile.less'; 24 | 25 | export interface EventMap { 26 | data: DataHandlerEventMap['data']; 27 | } 28 | 29 | export type EventNames = keyof EventMap; 30 | 31 | export interface MindmapTreeOptions { 32 | container: string | Element; 33 | data?: NodeDataMap; 34 | isDebug?: boolean; 35 | scale?: number; 36 | ydoc?: Y.Doc; 37 | } 38 | 39 | class MindmapTree { 40 | private readonly paperWrapper: PaperWrapper; 41 | private readonly viewportInteraction: ViewportInteraction; 42 | private readonly tree: Tree; 43 | private readonly keyboard: Keyboard; 44 | private readonly multiSelect: MultiSelect; 45 | private readonly nodeCreator: NodeCreator; 46 | private readonly dataHandler: DataHandler; 47 | private readonly selection: Selection; 48 | public constructor({ container, data, isDebug = false, scale, ydoc, }: MindmapTreeOptions) { 49 | setConfig({ isDebug }); 50 | this.paperWrapper = new PaperWrapper(container); 51 | const paper = this.paperWrapper.getPaper(); 52 | const viewport = new Viewport(this.paperWrapper, scale); 53 | this.nodeCreator = new NodeCreator({ 54 | paper, 55 | viewport, 56 | }); 57 | 58 | const initialData = getInitialData(data); 59 | 60 | this.tree = new Tree({ 61 | viewport, 62 | data: initialData, 63 | nodeCreator: this.nodeCreator, 64 | }); 65 | const root = this.tree.getRoot(); 66 | 67 | const selection = new Selection(root); 68 | this.selection = selection; 69 | 70 | const dataHandler = new DataHandler(selection, initialData, ydoc); 71 | this.dataHandler = dataHandler; 72 | 73 | dataHandler.on('data', ({ data, preSelectIds }) => { 74 | this.tree.render(data); 75 | if (preSelectIds.length > 0) { 76 | this.selection.selectByIds(preSelectIds); 77 | } 78 | }); 79 | 80 | const textEditor = new TextEditor({ 81 | viewport, 82 | selection, 83 | dataHandler, 84 | paperWrapper: this.paperWrapper, 85 | }); 86 | 87 | new NodeInteraction({ 88 | nodeCreator: this.nodeCreator, 89 | selection, 90 | textEditor, 91 | dataHandler, 92 | }); 93 | 94 | new SelectionBoundaryMove(selection, viewport); 95 | 96 | this.viewportInteraction = new ViewportInteraction({ 97 | paperWrapper: this.paperWrapper, 98 | viewport, 99 | root, 100 | }); 101 | 102 | const toolOperation = new ToolOperation({ 103 | root, 104 | tree: this.tree, 105 | selection, 106 | dataHandler, 107 | }); 108 | 109 | this.multiSelect = new MultiSelect({ 110 | paperWrapper: this.paperWrapper, 111 | viewport, 112 | selection, 113 | toolOperation, 114 | }); 115 | 116 | this.keyboard = new Keyboard({ 117 | toolOperation, 118 | selection, 119 | textEditor, 120 | multiSelect: this.multiSelect, 121 | viewportInteraction: this.viewportInteraction, 122 | }); 123 | 124 | new Toolbar({ 125 | paperWrapper: this.paperWrapper, 126 | toolOperation, 127 | selection, 128 | dataHandler, 129 | textEditor, 130 | viewportInteraction: this.viewportInteraction, 131 | }); 132 | 133 | new ViewportScale({ 134 | paperWrapper: this.paperWrapper, 135 | viewport, 136 | }); 137 | } 138 | 139 | public on(eventName: T, callback: EventMap[T]): void { 140 | if (eventName === 'data') { 141 | this.dataHandler.on(eventName, callback); 142 | } 143 | } 144 | 145 | public clear(): void { 146 | this.paperWrapper.clear(); 147 | this.nodeCreator.clear(); 148 | this.tree.clear(); 149 | this.viewportInteraction.clear(); 150 | this.multiSelect.clear(); 151 | this.keyboard.clear(); 152 | } 153 | 154 | public bindAwareness(awareness: Awareness): void { 155 | new AwarenessHandler(awareness, this.selection, this.tree.getRoot(), this.paperWrapper); 156 | } 157 | } 158 | 159 | export default MindmapTree; 160 | -------------------------------------------------------------------------------- /apps/core/src/keyboard/keyboard-events.ts: -------------------------------------------------------------------------------- 1 | 2 | type KeyboardEventName = 'keydown' | 'keyup' | 'keypress'; 3 | 4 | type KeyboardCallback = (event: KeyboardEvent) => void; 5 | 6 | class KeyboardEvents { 7 | private readonly allKeydownCallback: KeyboardCallback; 8 | 9 | private readonly allKeyupCallback: KeyboardCallback; 10 | 11 | private readonly allKeypressCallback: KeyboardCallback; 12 | private readonly keydownCallbacks: KeyboardCallback[] = []; 13 | private readonly keyupCallbacks: KeyboardCallback[] = []; 14 | private readonly keypressCallbacks: KeyboardCallback[] = []; 15 | public constructor() { 16 | this.allKeydownCallback = (event: KeyboardEvent) => { 17 | this.keydownCallbacks.forEach((callback) => callback(event)); 18 | }; 19 | document.addEventListener('keydown', this.allKeydownCallback); 20 | 21 | this.allKeyupCallback = (event: KeyboardEvent) => { 22 | this.keyupCallbacks.forEach((callback) => callback(event)); 23 | }; 24 | document.addEventListener('keyup', this.allKeyupCallback); 25 | 26 | this.allKeypressCallback = (event: KeyboardEvent) => { 27 | this.keypressCallbacks.forEach((callback) => callback(event)); 28 | }; 29 | document.addEventListener('keypress', this.allKeypressCallback); 30 | } 31 | 32 | public on(eventName: KeyboardEventName, callback: KeyboardCallback): void { 33 | if (eventName === 'keydown') { 34 | this.keydownCallbacks.push(callback); 35 | } else if (eventName === 'keyup') { 36 | this.keyupCallbacks.push(callback); 37 | } else if (eventName === 'keypress') { 38 | this.keypressCallbacks.push(callback); 39 | } 40 | } 41 | 42 | public off(eventName: KeyboardEventName, callback: KeyboardCallback): void { 43 | if (eventName === 'keydown') { 44 | this.removeCallback(this.keydownCallbacks, callback); 45 | } else if (eventName === 'keyup') { 46 | this.removeCallback(this.keyupCallbacks, callback); 47 | } else if (eventName === 'keypress') { 48 | this.removeCallback(this.keypressCallbacks, callback); 49 | } 50 | } 51 | 52 | public clear(): void { 53 | document.removeEventListener('keydown', this.allKeydownCallback); 54 | document.removeEventListener('keyup', this.allKeyupCallback); 55 | document.removeEventListener('keypress', this.allKeypressCallback); 56 | } 57 | 58 | private removeCallback(callbacks: KeyboardCallback[], callback: KeyboardCallback): void { 59 | const index = callbacks.findIndex((aCallback) => aCallback === callback); 60 | if (index > -1) { 61 | callbacks.splice(index, 1); 62 | } 63 | } 64 | } 65 | 66 | export default KeyboardEvents; 67 | -------------------------------------------------------------------------------- /apps/core/src/keyboard/keyboard.ts: -------------------------------------------------------------------------------- 1 | import KeyboardEvents from './keyboard-events'; 2 | import TextEditor from '../text-editor'; 3 | import Selection from '../selection/selection'; 4 | import MultiSelect from '../selection/multi-select'; 5 | import ViewportInteraction from '../viewport-interaction/viewport-interaction'; 6 | import ToolOperation from '../tool-operation'; 7 | import { isWindows } from '../helper'; 8 | import type { ArrowType } from '../selection/selection-arrow-next'; 9 | 10 | interface KeyboardOptions { 11 | toolOperation: ToolOperation; 12 | textEditor: TextEditor; 13 | selection: Selection; 14 | multiSelect: MultiSelect; 15 | viewportInteraction: ViewportInteraction; 16 | } 17 | 18 | class Keyboard { 19 | private readonly keyboardEvents: KeyboardEvents; 20 | public constructor(options: KeyboardOptions) { 21 | this.keyboardEvents = new KeyboardEvents(); 22 | 23 | this.keyboardEvents.on('keydown', (event: KeyboardEvent) => { 24 | this.handleKeydown(event, options); 25 | }); 26 | 27 | this.keyboardEvents.on('keyup', (event: KeyboardEvent) => { 28 | this.handleKeyup(event, options); 29 | }); 30 | } 31 | 32 | private handleKeydown = (event: KeyboardEvent, { 33 | toolOperation, 34 | textEditor, 35 | selection, 36 | multiSelect, 37 | viewportInteraction, 38 | }: KeyboardOptions): void => { 39 | const { key, ctrlKey, shiftKey, metaKey } = event; 40 | 41 | if (textEditor.isShowing()) { 42 | switch (key) { 43 | case 'Escape': { 44 | textEditor.hide(); 45 | break; 46 | } 47 | case 'Tab': 48 | case 'Enter': { 49 | textEditor.finishEdit(); 50 | break; 51 | } 52 | } 53 | return; 54 | } 55 | 56 | const realCtrlKey = isWindows() ? ctrlKey : metaKey; 57 | 58 | if (realCtrlKey && shiftKey) { 59 | switch (key) { 60 | // ctrl + shift + z 61 | case 'z': { 62 | toolOperation.redo(); 63 | break; 64 | } 65 | } 66 | } else if (realCtrlKey && !shiftKey) { 67 | switch (key) { 68 | // ctrl + z 69 | case 'z': { 70 | toolOperation.undo(); 71 | break; 72 | } 73 | case '=': { 74 | event.preventDefault(); 75 | viewportInteraction.zoomIn(); 76 | break; 77 | } 78 | case '-': { 79 | event.preventDefault(); 80 | viewportInteraction.zoomOut(); 81 | break; 82 | } 83 | case 'Meta': 84 | case 'Alt': { 85 | selection.setIsMultiClickMode(true); 86 | multiSelect.disable(); 87 | viewportInteraction.enableBackgroundDrag(); 88 | viewportInteraction.enableMoveScale(); 89 | break; 90 | } 91 | default: { 92 | break 93 | } 94 | } 95 | } else if (!metaKey && !shiftKey && !ctrlKey) { 96 | switch (key) { 97 | case 'Enter': { 98 | toolOperation.addBrotherNode(); 99 | break; 100 | } 101 | case 'Tab': { 102 | const selectNodes = selection.getSelectNodes(); 103 | if (selectNodes.length > 0) { 104 | event.preventDefault(); 105 | } 106 | toolOperation.addChildNode(); 107 | break; 108 | } 109 | case 'Backspace': { 110 | toolOperation.removeNode(); 111 | break; 112 | } 113 | case 'ArrowUp': 114 | case 'ArrowRight': 115 | case 'ArrowDown': 116 | case 'ArrowLeft': { 117 | selection.selectArrowNext(key as ArrowType); 118 | break; 119 | } 120 | default: { 121 | break; 122 | } 123 | } 124 | } 125 | } 126 | 127 | private handleKeyup = (event: KeyboardEvent, { 128 | selection, 129 | multiSelect, 130 | viewportInteraction, 131 | }: KeyboardOptions): void => { 132 | switch (event.key) { 133 | case 'Meta': 134 | case 'Alt': { 135 | selection.setIsMultiClickMode(false); 136 | multiSelect.enable(); 137 | viewportInteraction.disableBackgroundDrag(); 138 | viewportInteraction.disableMoveScale(); 139 | break; 140 | } 141 | default: { 142 | break; 143 | } 144 | } 145 | } 146 | 147 | public clear() { 148 | this.keyboardEvents.clear(); 149 | } 150 | } 151 | 152 | export default Keyboard; 153 | -------------------------------------------------------------------------------- /apps/core/src/mobile.less: -------------------------------------------------------------------------------- 1 | body { 2 | -webkit-tap-highlight-color: transparent; 3 | } 4 | 5 | .mobile.mindmap-graph .toolbar { 6 | .toolbar-item { 7 | .toolbar-btn { 8 | 9 | &:hover { 10 | background-color: transparent; 11 | } 12 | 13 | &:active { 14 | background-color: transparent; 15 | } 16 | } 17 | 18 | &:hover .toolbar-tip { 19 | display: none; 20 | } 21 | } 22 | } 23 | 24 | .mobile.mindmap-graph .viewport-scale-controller { 25 | right: auto; 26 | left: 50%; 27 | bottom: 23px; 28 | transform: translateX(-50%); 29 | height: 60px; 30 | 31 | .scale-label { 32 | width: 64px; 33 | font-size: 18px; 34 | 35 | &:hover { 36 | background-color: transparent; 37 | } 38 | } 39 | 40 | .scale-btn { 41 | .scale-btn-icon { 42 | width: 20px; 43 | height: 20px; 44 | } 45 | 46 | &:hover { 47 | background-color: transparent; 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /apps/core/src/node-interaction.ts: -------------------------------------------------------------------------------- 1 | import NodeCreator from "./node/node-creator"; 2 | import Node from './node/node'; 3 | import Selection from './selection/selection'; 4 | import TextEditor from "./text-editor"; 5 | import { HitArea } from "./drag/drag-area"; 6 | import DataHandler from './data/data-handler'; 7 | import { isMobile } from './helper'; 8 | 9 | class NodeInteraction { 10 | public constructor({ 11 | nodeCreator, 12 | selection, 13 | textEditor, 14 | dataHandler, 15 | }: { 16 | nodeCreator: NodeCreator; 17 | selection: Selection; 18 | textEditor: TextEditor; 19 | dataHandler: DataHandler; 20 | }) { 21 | const mousedownName = isMobile ? 'touchstart' : 'mousedown'; 22 | 23 | nodeCreator.on(mousedownName, (node: Node, event: MouseEvent) => { 24 | event.stopPropagation(); 25 | selection.selectSingle(node); 26 | }); 27 | 28 | nodeCreator.on('dblclick', () => { 29 | textEditor.showBySelectionLabel(); 30 | }); 31 | 32 | nodeCreator.on('dragEnd', (node: Node, hitArea: HitArea) => { 33 | if (hitArea !== null && !hitArea.isOwnArea) { 34 | dataHandler.changeFather({ 35 | selectionId: node.id, 36 | newFatherId: hitArea.father.id, 37 | direction: hitArea.direction, 38 | childIndex: hitArea.childIndex, 39 | }); 40 | } 41 | }); 42 | 43 | nodeCreator.on('mousedownExpander', (node: Node, isExpand) => { 44 | dataHandler.update(node.id, { 45 | isExpand, 46 | }) 47 | selection.select([node]); 48 | }); 49 | } 50 | } 51 | 52 | export default NodeInteraction; -------------------------------------------------------------------------------- /apps/core/src/node/expander.ts: -------------------------------------------------------------------------------- 1 | 2 | import EventEmitter from 'eventemitter3'; 3 | import Position from '../position'; 4 | import Node from './node'; 5 | import NodeShape from '../shape/node-shape'; 6 | import ExpanderShape from '../shape/expander-shape'; 7 | import type { RaphaelPaper } from 'raphael'; 8 | import type { TraverseFunc, TraverseOptions } from './node'; 9 | import { isMobile } from '../helper'; 10 | 11 | export interface ExpanderEventMap { 12 | mousedownExpander: (newIsExpander: boolean) => void; 13 | } 14 | 15 | class Expander { 16 | private readonly paper: RaphaelPaper; 17 | private readonly node: Node; 18 | private readonly nodeShape: NodeShape; 19 | private readonly eventEmitter: EventEmitter; 20 | private readonly traverse: TraverseFunc; 21 | private expanderShape: ExpanderShape | null = null; 22 | private isExpand: boolean; 23 | public constructor({ 24 | paper, 25 | node, 26 | nodeShape, 27 | isExpand, 28 | traverse, 29 | }: { 30 | paper: RaphaelPaper; 31 | node: Node; 32 | nodeShape: NodeShape; 33 | isExpand: boolean; 34 | traverse: TraverseFunc; 35 | }) { 36 | this.paper = paper; 37 | this.node = node; 38 | this.nodeShape = nodeShape; 39 | this.isExpand = isExpand; 40 | this.traverse = traverse; 41 | this.eventEmitter = new EventEmitter(); 42 | } 43 | 44 | public getIsExpand(): boolean { 45 | return this.isExpand; 46 | } 47 | 48 | public changeExpand(newIsExpand: boolean): void { 49 | if (this.isExpand === newIsExpand) return; 50 | 51 | if (this.node.children.length === 0) { 52 | this.remove(); 53 | return; 54 | } 55 | 56 | this.expanderShape?.changeExpand(newIsExpand); 57 | this.isExpand = newIsExpand; 58 | 59 | if (!newIsExpand) { 60 | this.node.children.forEach((child) => { 61 | this.traverse(child, this.hideNode); 62 | }); 63 | } 64 | } 65 | 66 | public create(): void { 67 | this.createShape(); 68 | this.translateShape(); 69 | } 70 | 71 | public remove(): void { 72 | this.expanderShape?.remove(); 73 | this.expanderShape = null; 74 | this.eventEmitter.removeAllListeners(); 75 | } 76 | 77 | public on>( 78 | eventName: T, 79 | callback: EventEmitter.EventListener 80 | ) { 81 | this.eventEmitter.on(eventName, callback); 82 | } 83 | 84 | public setStyle(styleType: 'disable' | 'base'): void { 85 | this.expanderShape?.setStyle(styleType); 86 | } 87 | 88 | private createShape(): void { 89 | if ( 90 | this.node.isRoot() || this.expanderShape !== null 91 | || this.node.children.length === 0 92 | || this.nodeShape.getIsHide() 93 | ) return; 94 | 95 | this.expanderShape = new ExpanderShape({ 96 | paper: this.paper, 97 | nodeBBox: this.node.getBBox(), 98 | isExpand: this.isExpand, 99 | direction: this.node.direction!, 100 | }); 101 | 102 | const mousedownName = isMobile ? 'touchstart' : 'mousedown'; 103 | this.expanderShape?.on(mousedownName, (event: MouseEvent) => { 104 | event.stopPropagation(); 105 | const newIsExpand = !this.isExpand; 106 | this.changeExpand(newIsExpand); 107 | 108 | this.eventEmitter.emit('mousedownExpander', newIsExpand); 109 | }); 110 | } 111 | 112 | private translateShape(): void { 113 | this.expanderShape?.translateTo(this.node.getBBox(), this.node.direction!); 114 | } 115 | 116 | private hideNode({ 117 | nodeShape, 118 | expander, 119 | removeEdgeShape, 120 | }: TraverseOptions): void { 121 | nodeShape.hide(); 122 | expander.remove(); 123 | removeEdgeShape(); 124 | } 125 | } 126 | 127 | export default Expander; 128 | -------------------------------------------------------------------------------- /apps/core/src/node/node-creator.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3'; 2 | import Node from '../node/node'; 3 | import Viewport from '../viewport'; 4 | import { generateId } from '../helper'; 5 | import type { RaphaelPaper } from 'raphael'; 6 | import type { NodeOptions, NodeEventMap, NodeEventNames } from '../node/node'; 7 | import type { ImageData } from '../types'; 8 | 9 | export interface CreateNodeParams { 10 | id?: NodeOptions['id']; 11 | depth: NodeOptions['depth']; 12 | label: NodeOptions['label']; 13 | direction: NodeOptions['direction']; 14 | x?: NodeOptions['x']; 15 | y?: NodeOptions['y']; 16 | father?: NodeOptions['father']; 17 | isExpand?: NodeOptions['isExpand']; 18 | imageData?: ImageData; 19 | link?: string; 20 | } 21 | 22 | export type CreateNodeFunc = (params: CreateNodeParams) => Node; 23 | 24 | type NodeCallbackArgs = Parameters; 25 | type NodeCreatorCallback = ( 26 | node: Node, 27 | ...args: Parameters 28 | ) => void; 29 | 30 | interface NodeCreatorEventMap { 31 | mousedown: NodeCreatorCallback<'mousedown'>; 32 | click: NodeCreatorCallback<'click'>; 33 | dblclick: NodeCreatorCallback<'dblclick'>; 34 | touchstart: NodeCreatorCallback<'touchstart'>; 35 | mousedownExpander: NodeCreatorCallback<'mousedownExpander'>; 36 | dragEnd: NodeCreatorCallback<'dragEnd'>; 37 | } 38 | 39 | type NodeCreatorEventNames = keyof NodeCreatorEventMap; 40 | 41 | // only support the events with one callback 42 | const nodeCreatorEventNames: NodeCreatorEventNames[] = ['mousedown', 'click', 'dblclick', 'touchstart', 'mousedownExpander', 'dragEnd']; 43 | 44 | class NodeCreator { 45 | private readonly paper: RaphaelPaper; 46 | private readonly viewport: Viewport; 47 | private readonly eventEmitter: EventEmitter; 48 | public constructor({ 49 | paper, 50 | viewport, 51 | }: { 52 | paper: RaphaelPaper; 53 | viewport: Viewport; 54 | }) { 55 | this.paper = paper; 56 | this.viewport = viewport; 57 | this.eventEmitter = new EventEmitter(); 58 | } 59 | 60 | public createNode = ({ 61 | id, 62 | depth, 63 | label, 64 | direction, 65 | x, 66 | y, 67 | father, 68 | isExpand, 69 | imageData, 70 | link, 71 | }: CreateNodeParams): Node => { 72 | const newNode = new Node({ 73 | paper: this.paper, 74 | id: id || generateId(), 75 | depth, 76 | label, 77 | direction, 78 | x, 79 | y, 80 | father, 81 | isExpand, 82 | viewport: this.viewport, 83 | imageData, 84 | link, 85 | }); 86 | 87 | nodeCreatorEventNames.forEach((eventName) => { 88 | newNode.on(eventName, (...args: NodeCallbackArgs) => { 89 | // @ts-ignore 90 | this.eventEmitter.emit(eventName, newNode, ...args); 91 | }); 92 | }); 93 | 94 | return newNode; 95 | } 96 | 97 | public on( 98 | eventName: EventName, 99 | callback: NodeCreatorEventMap[EventName] 100 | ): void { 101 | // @ts-ignore 102 | this.eventEmitter.on(eventName, callback); 103 | } 104 | 105 | public clear(): void { 106 | this.eventEmitter.removeAllListeners(); 107 | } 108 | }; 109 | 110 | export default NodeCreator; 111 | -------------------------------------------------------------------------------- /apps/core/src/node/node.ts: -------------------------------------------------------------------------------- 1 | import Viewport from '../viewport'; 2 | import Expander from './expander'; 3 | import Drag from '../drag/drag'; 4 | import NodeShape from '../shape/node-shape'; 5 | import CollaborateShape from '../shape/collaborate-shape'; 6 | import ShapeGenerator from './shape-generator'; 7 | import { getDepthType, DepthType } from '../helper'; 8 | import { Direction } from '../types'; 9 | import type { ExpanderEventMap } from './expander'; 10 | import type { RaphaelPaper, RaphaelAxisAlignedBoundingBox } from 'raphael'; 11 | import type { DragEventMap } from '../drag/drag'; 12 | import type { EdgeShape } from './shape-generator'; 13 | import type { EventNames as ShapeEventNames, EventArgs as ShapeEventArgs } from '../shape/common/shape-event-emitter'; 14 | import type { StyleType } from '../shape/common/node-shape-style'; 15 | import type { ImageData } from '../types'; 16 | 17 | export interface TraverseOptions { 18 | node: Node; 19 | nodeShape: NodeShape; 20 | edgeShape: EdgeShape | null; 21 | expander: Expander; 22 | removeEdgeShape: () => void; 23 | } 24 | export type TraverseFunc = (node: Node, callback: (options: TraverseOptions) => void) => void; 25 | 26 | export interface NodeEventMap { 27 | mousedown: ShapeEventArgs<'mousedown'>; 28 | click: ShapeEventArgs<'click'>; 29 | dblclick: ShapeEventArgs<'dblclick'>; 30 | drag: ShapeEventArgs<'drag'>; 31 | touchstart: ShapeEventArgs<'touchstart'>; 32 | mousedownExpander: [ExpanderEventMap['mousedownExpander']]; 33 | dragEnd: [DragEventMap['dragEnd']]; 34 | }; 35 | 36 | export type NodeEventNames = keyof NodeEventMap; 37 | 38 | export interface NodeOptions { 39 | paper: RaphaelPaper; 40 | id: string; 41 | depth: number; 42 | label: string; 43 | direction: Direction; 44 | x?: number; 45 | y?: number; 46 | father?: Node | null; 47 | isExpand?: boolean; 48 | viewport: Viewport; 49 | imageData?: ImageData | null; 50 | link?: string; 51 | } 52 | 53 | class Node { 54 | private readonly paper: RaphaelPaper; 55 | private readonly shapeGenerator: ShapeGenerator; 56 | private readonly _id: string; 57 | private readonly _depth: number; 58 | private readonly _father: Node | null = null; 59 | private readonly nodeShape: NodeShape; 60 | private readonly expander: Expander; 61 | private readonly drag: Drag | null = null; 62 | private readonly _imageData: ImageData | null = null; 63 | private readonly _link: string = ''; 64 | private _label: string; 65 | private _direction: Direction; 66 | private _children: Node[]; 67 | private edgeShape: EdgeShape | null = null; 68 | private collaborateShape: CollaborateShape | null = null; 69 | 70 | public constructor({ 71 | paper, 72 | id, 73 | depth, 74 | label, 75 | direction, 76 | x, 77 | y, 78 | father = null, 79 | isExpand, 80 | viewport, 81 | imageData, 82 | link, 83 | }: NodeOptions) { 84 | this.paper = paper; 85 | this._id = id; 86 | this._depth = depth; 87 | this._direction = direction; 88 | this._father = father || null; 89 | this._label = label; 90 | this._children = []; 91 | this._imageData = imageData || null; 92 | this._link = link || ''; 93 | 94 | this.shapeGenerator = new ShapeGenerator({ 95 | paper, 96 | depth, 97 | label, 98 | direction, 99 | father, 100 | imageData, 101 | link, 102 | }); 103 | 104 | // render node & edge 105 | this.nodeShape = this.shapeGenerator.createNode(x, y); 106 | if (x !== undefined || y !== undefined) { 107 | this.edgeShape = this.shapeGenerator.createEdge(this.nodeShape); 108 | } 109 | 110 | if (!this.isRoot()) { 111 | this.drag = new Drag({ 112 | paper, 113 | node: this, 114 | viewport, 115 | nodeShape: this.nodeShape, 116 | traverse: this.traverse, 117 | }); 118 | } 119 | 120 | this.expander = new Expander({ 121 | paper, 122 | node: this, 123 | nodeShape: this.nodeShape, 124 | isExpand: isExpand === undefined ? true : isExpand, 125 | traverse: this.traverse, 126 | }); 127 | } 128 | 129 | public get id() { return this._id; } 130 | public get depth() { return this._depth; } 131 | public get direction() { return this._direction; } 132 | public get father() { return this._father; } 133 | public get label() { return this._label; } 134 | public get children() { return this._children; } 135 | public get isExpand() { return this.expander.getIsExpand(); } 136 | public get imageData() { return this._imageData; } 137 | public get link() { return this._link; } 138 | 139 | public getDirectionChildren(direction: Direction): Node[] { 140 | return this.children?.filter((child) => { 141 | return child.direction === direction; 142 | }) || []; 143 | } 144 | 145 | public getBBox(): RaphaelAxisAlignedBoundingBox { 146 | return this.nodeShape.getBBox()!; 147 | } 148 | 149 | public getLabelBBox(): RaphaelAxisAlignedBoundingBox { 150 | return this.nodeShape.getLabelBBox(); 151 | } 152 | 153 | public getDepthType(): DepthType { 154 | return getDepthType(this.depth); 155 | } 156 | 157 | public getRoot(): Node | null { 158 | let root: Node | null = this; 159 | while (root && root.getDepthType() !== DepthType.root) { 160 | root = root.father; 161 | } 162 | return root; 163 | } 164 | 165 | public isRoot(): boolean { 166 | return this.getDepthType() === DepthType.root; 167 | } 168 | 169 | public on(eventName: T, ...args: NodeEventMap[T]): void { 170 | const shapeEventNames: NodeEventNames[] = ['mousedown', 'click', 'dblclick', 'drag', 'touchstart']; 171 | const expanderEventNames: NodeEventNames[] = ['mousedownExpander']; 172 | const dragEventNames: NodeEventNames[] = ['dragEnd']; 173 | 174 | if (shapeEventNames.includes(eventName)) { 175 | if (eventName === 'drag' && !this.isRoot()) { 176 | return; 177 | } 178 | this.nodeShape.on(eventName as ShapeEventNames, ...args as ShapeEventArgs); 179 | } else if (expanderEventNames.includes(eventName)) { 180 | this.expander.on(eventName as keyof ExpanderEventMap, ...args as [ExpanderEventMap[keyof ExpanderEventMap]]); 181 | } else if (dragEventNames.includes(eventName)) { 182 | this.drag?.on(eventName as keyof DragEventMap, ...args as [DragEventMap[keyof DragEventMap]]) 183 | } 184 | } 185 | 186 | public clearChild(): void { 187 | this._children = []; 188 | } 189 | 190 | public pushChild(child: Node): void { 191 | this.children.push(child); 192 | } 193 | 194 | public changeExpand(isExpand: boolean): void { 195 | if (isExpand === undefined) return; 196 | this.expander.changeExpand(isExpand); 197 | } 198 | 199 | public changeDirection(direction: Direction): void { 200 | this._direction = direction; 201 | this.shapeGenerator.changeDirection(direction); 202 | } 203 | 204 | public translateTo(x: number, y: number) { 205 | this.nodeShape.translateTo(x, y); 206 | 207 | this.removeEdgeShape(); 208 | this.edgeShape = this.shapeGenerator.createEdge(this.nodeShape); 209 | 210 | this.father?.expander.create(); 211 | this.expander.create(); 212 | } 213 | 214 | public remove(): void { 215 | if (!this.isRoot()) { 216 | const brothers = this.father?.children; 217 | if (!brothers) return; 218 | 219 | // Remove the relationship from father node's children 220 | const index = brothers?.findIndex((brother) => this.id === brother.id); 221 | if (index > -1) { 222 | brothers.splice(index, 1); 223 | } 224 | 225 | // If brothers is empty, then remove the expander of father 226 | if (brothers.length === 0) { 227 | this.father?.expander.remove(); 228 | } 229 | } 230 | 231 | this.traverse(this, ({ node: curNode }) => { 232 | curNode.nodeShape.remove(); 233 | curNode.removeEdgeShape(); 234 | curNode.expander.remove(); 235 | curNode.drag?.clear(); 236 | curNode.collaborateShape?.remove(); 237 | }); 238 | } 239 | 240 | public setLabel(label: string): void { 241 | this._label = label; 242 | this.nodeShape.setLabel(label, this.direction); 243 | this.collaborateShape?.setPosition(this.getBBox()); 244 | } 245 | 246 | public setStyle(styleType: StyleType): void { 247 | this.nodeShape.setStyle(styleType); 248 | } 249 | 250 | public nodeShapeToFront(): void { 251 | this.nodeShape.toFront(); 252 | } 253 | 254 | public isInvisible(): boolean { 255 | return this.nodeShape.isInvisible(); 256 | } 257 | 258 | public setCollaborateStyle(style: { color: string; name: string; } | null): void { 259 | if (style) { 260 | this.collaborateShape = new CollaborateShape({ 261 | paper: this.paper, 262 | nodeBBox: this.getBBox(), 263 | name: style.name, 264 | color: style.color, 265 | }); 266 | } else { 267 | this.collaborateShape?.remove(); 268 | } 269 | } 270 | 271 | private traverse(node: Node, callback: (options: TraverseOptions) => void): void { 272 | callback({ 273 | node, 274 | nodeShape: node.nodeShape, 275 | expander: node.expander, 276 | edgeShape: node.edgeShape, 277 | removeEdgeShape: node.removeEdgeShape, 278 | }); 279 | node.children.forEach((child) => this.traverse(child, callback)); 280 | } 281 | 282 | private removeEdgeShape = (): void => { 283 | this.edgeShape?.remove(); 284 | this.edgeShape = null; 285 | } 286 | } 287 | 288 | export default Node; 289 | -------------------------------------------------------------------------------- /apps/core/src/node/shape-generator.ts: -------------------------------------------------------------------------------- 1 | 2 | import Node from './node'; 3 | import NodeShape from '../shape/node-shape'; 4 | import { createFirstNodeShape } from '../shape/first-node-shape'; 5 | import { createGrandchildNodeShape } from '../shape/grandchild-node-shape'; 6 | import { createRootNodeShape } from '../shape/root-node-shape'; 7 | import { createFirstEdgeShape, FirstEdgeShape } from '../shape/first-edge-shape'; 8 | import { createGrandchildEdgeShape, GrandchildEdgeShape } from '../shape/grandchild-edge-shape'; 9 | import { getDepthType, DepthType } from '../helper'; 10 | import { Direction } from '../types'; 11 | import type { RaphaelPaper } from 'raphael'; 12 | import type { ImageData } from '../types'; 13 | 14 | export type EdgeShape = FirstEdgeShape | GrandchildEdgeShape; 15 | 16 | // generate Node and Edge for rendering 17 | class ShapeGenerator { 18 | private readonly paper: RaphaelPaper; 19 | private readonly depth: number; 20 | private readonly label: string; 21 | private readonly father: Node | null = null; 22 | private readonly imageData: ImageData | null = null; 23 | private readonly link: string = ''; 24 | private direction: Direction; 25 | public constructor({ 26 | paper, 27 | depth, 28 | label, 29 | direction, 30 | father, 31 | imageData, 32 | link, 33 | }: { 34 | paper: RaphaelPaper, 35 | depth: number, 36 | label: string, 37 | direction: Direction, 38 | father: Node | null, 39 | imageData?: ImageData | null; 40 | link?: string; 41 | }) { 42 | this.paper = paper; 43 | this.depth = depth; 44 | this.label = label; 45 | this.father = father; 46 | this.direction = direction; 47 | this.imageData = imageData || null; 48 | this.link = link || ''; 49 | } 50 | 51 | public createNode(x?: number, y?: number): NodeShape { 52 | const { 53 | paper, 54 | depth, 55 | label, 56 | imageData, 57 | link, 58 | } = this; 59 | 60 | const nodeOptions = { 61 | paper, 62 | x, 63 | y, 64 | label, 65 | imageData, 66 | link, 67 | }; 68 | 69 | const depthType = getDepthType(depth); 70 | if (depthType === DepthType.root) { 71 | return createRootNodeShape(nodeOptions); 72 | } else if (depthType === DepthType.firstLevel) { 73 | return createFirstNodeShape(nodeOptions); 74 | } else { 75 | return createGrandchildNodeShape(nodeOptions); 76 | } 77 | } 78 | 79 | public createEdge(nodeShape: NodeShape): EdgeShape | null { 80 | const { 81 | father, 82 | direction, 83 | depth, 84 | } = this; 85 | 86 | if (!father || !direction) { 87 | return null; 88 | } 89 | 90 | const depthType = getDepthType(depth); 91 | 92 | if (depthType === DepthType.firstLevel) { 93 | return createFirstEdgeShape({ 94 | paper: this.paper, 95 | sourceBBox: father.getBBox(), 96 | targetBBox: nodeShape.getBBox(), 97 | direction, 98 | }) 99 | 100 | } else if (depthType === DepthType.grandchild) { 101 | return createGrandchildEdgeShape({ 102 | paper: this.paper, 103 | sourceBBox: father.getBBox(), 104 | targetBBox: nodeShape.getBBox(), 105 | direction, 106 | targetDepth: this.depth, 107 | }) 108 | } 109 | 110 | return null; 111 | } 112 | 113 | public changeDirection(direction: Direction) { 114 | this.direction = direction; 115 | } 116 | } 117 | 118 | export default ShapeGenerator; 119 | -------------------------------------------------------------------------------- /apps/core/src/paper-wrapper.ts: -------------------------------------------------------------------------------- 1 | import Raphael from 'raphael'; 2 | import { isMobile } from './helper'; 3 | import type { RaphaelPaper } from 'raphael'; 4 | 5 | export const wrapperClassName = 'mindmap-graph'; 6 | 7 | class PaperWrapper { 8 | private readonly containerDom: HTMLElement; 9 | private readonly wrapperDom: HTMLDivElement; 10 | private readonly svgDom: SVGSVGElement | null; 11 | private readonly paper: RaphaelPaper; 12 | public constructor(container: string | Element) { 13 | const graphElement = this.initGraphElement(container); 14 | this.containerDom = graphElement.containerDom; 15 | this.wrapperDom = graphElement.wrapperDom; 16 | const { clientWidth, clientHeight } = this.wrapperDom; 17 | this.paper = new Raphael(this.wrapperDom, clientWidth, clientHeight); 18 | this.svgDom = document.querySelector('svg') || null; 19 | } 20 | 21 | public getPaper(): RaphaelPaper { 22 | return this.paper; 23 | } 24 | public getWrapperDom(): HTMLDivElement { 25 | return this.wrapperDom; 26 | } 27 | 28 | public getContainerDom(): HTMLElement { 29 | return this.containerDom; 30 | } 31 | 32 | public getSvgDom(): SVGSVGElement | null { 33 | return this.svgDom; 34 | } 35 | 36 | public getSize(): { width: number; height: number; } { 37 | return { 38 | width: this.wrapperDom.clientWidth, 39 | height: this.wrapperDom.clientHeight, 40 | } 41 | } 42 | 43 | public clear(): void { 44 | this.paper.clear(); 45 | this.wrapperDom.remove(); 46 | } 47 | 48 | private initGraphElement(container: string | Element): { 49 | containerDom: HTMLElement; 50 | wrapperDom: HTMLDivElement; 51 | } { 52 | const containerDom = (typeof container === 'string' ? document.querySelector(container) : container) as HTMLElement; 53 | if (!containerDom) { 54 | throw new Error('container is not exist'); 55 | } 56 | 57 | if (containerDom.clientWidth === 0 || containerDom.clientHeight === 0) { 58 | throw new Error('The width or height of Container is not more than 0') 59 | } 60 | 61 | const backgroundDom = document.createElement('div'); 62 | backgroundDom.className = 'mindmap-graph-background'; 63 | 64 | const wrapperDom = document.createElement('div'); 65 | wrapperDom.className = `${wrapperClassName}${isMobile ? ' mobile' : ''}`; 66 | containerDom.appendChild(wrapperDom); 67 | containerDom.appendChild(backgroundDom); 68 | 69 | return { 70 | containerDom, 71 | wrapperDom, 72 | }; 73 | } 74 | } 75 | 76 | export default PaperWrapper; 77 | -------------------------------------------------------------------------------- /apps/core/src/position.ts: -------------------------------------------------------------------------------- 1 | import Node from './node/node'; 2 | import { Direction } from './types'; 3 | import { getNodeYGap, getNodeXGap } from './shape/gap'; 4 | 5 | // areaHeight is combined of all children node areaHeight 6 | class AreaHeight { 7 | private readonly areaHeightMap: Record = {}; 8 | public constructor() { } 9 | public getAreaHeight(node: Node, direction: Direction): number { 10 | const nodeHeight = node.getBBox().height; 11 | if (!node.isExpand) { 12 | return nodeHeight; 13 | } 14 | 15 | // use cache 16 | const areaKey = `${node.id}_${direction}`; 17 | if (this.areaHeightMap[areaKey]) { 18 | return this.areaHeightMap[areaKey]; 19 | } 20 | 21 | let areaHeight = 0; 22 | 23 | const children = node.getDirectionChildren(direction); 24 | 25 | if (children.length === 0) { 26 | areaHeight = nodeHeight; 27 | } else { 28 | areaHeight = this.getChildrenAreaHeight(children, direction); 29 | 30 | areaHeight = Math.max(areaHeight, nodeHeight); 31 | } 32 | 33 | this.areaHeightMap[node.id] = areaHeight; 34 | 35 | return areaHeight; 36 | } 37 | public getChildrenAreaHeight(children: Node[], direction: Direction): number { 38 | if (children.length === 0) return 0; 39 | 40 | const childrenAreaHeight = children.reduce((total, child) => { 41 | const childAreaHeight = this.getAreaHeight(child, direction); 42 | return total + childAreaHeight; 43 | }, 0); 44 | 45 | const childGap = getNodeYGap(children[0].depth + 1); 46 | 47 | return childrenAreaHeight + (children.length - 1) * childGap; 48 | } 49 | } 50 | 51 | // set all nodes' position 52 | class Position { 53 | private root: Node | null; 54 | public constructor(root?: Node | null) { 55 | this.root = root === undefined ? null : root; 56 | } 57 | 58 | public init(root: Node): void { 59 | this.root = root; 60 | } 61 | 62 | public reset(direction?: Direction): void { 63 | if (!this.root) { 64 | throw new Error('Position is called without root.'); 65 | } 66 | 67 | const areaHeightHandler = new AreaHeight(); 68 | if (direction === Direction.LEFT || direction === Direction.RIGHT) { 69 | this.resetInner({ node: this.root, direction, areaHeightHandler }); 70 | } else { 71 | this.resetInner({ node: this.root, direction: Direction.LEFT, areaHeightHandler }); 72 | this.resetInner({ node: this.root, direction: Direction.RIGHT, areaHeightHandler }); 73 | } 74 | } 75 | 76 | private resetInner({ 77 | node, 78 | direction, 79 | areaHeightHandler, 80 | }: { 81 | node: Node; 82 | direction: Direction; 83 | areaHeightHandler: AreaHeight; 84 | fatherAreaHeight?: number 85 | }): void { 86 | if (!node.isExpand) { 87 | return; 88 | } 89 | 90 | const childDepth = node.depth + 1; 91 | const yGap = getNodeYGap(childDepth); 92 | const children = node.getDirectionChildren(direction); 93 | const nodeBBox = node.getBBox(); 94 | const childrenAreaHeight = areaHeightHandler.getChildrenAreaHeight(children, direction); 95 | let startY = nodeBBox.cy - (childrenAreaHeight / 2); 96 | 97 | children.forEach((child) => { 98 | const childAreaHeight = areaHeightHandler.getAreaHeight(child, direction); 99 | 100 | const childBBox = child.getBBox(); 101 | const xGap = getNodeXGap(childDepth); 102 | 103 | const childX = direction === Direction.RIGHT ? (nodeBBox.x2 + xGap) : (nodeBBox.x - xGap - childBBox.width); 104 | const childY = startY + (childAreaHeight / 2) - (childBBox.height / 2); 105 | 106 | child.translateTo(childX, childY); 107 | 108 | this.resetInner({ node: child, direction, areaHeightHandler }); 109 | 110 | startY += childAreaHeight + yGap; 111 | }); 112 | } 113 | } 114 | 115 | export default Position; 116 | -------------------------------------------------------------------------------- /apps/core/src/selection/multi-select.ts: -------------------------------------------------------------------------------- 1 | import Raphael from 'raphael'; 2 | import MultiSelectShape from '../shape/multi-select-shape'; 3 | import Viewport from '../viewport'; 4 | import Node from '../node/node'; 5 | import Selection from './selection'; 6 | import PaperWrapper from '../paper-wrapper'; 7 | import ToolOperation from '../tool-operation'; 8 | import { isMobile } from '../helper'; 9 | 10 | const validDiff = 2; 11 | 12 | class MultiSelect { 13 | private readonly multiSelectShape: MultiSelectShape; 14 | private readonly viewport: Viewport; 15 | private readonly selection: Selection; 16 | private readonly svgDom: SVGSVGElement | null; 17 | private readonly toolOperation: ToolOperation; 18 | private allNodes: Node[] = []; 19 | private preSelectNodes: Node[] = []; 20 | private able: boolean = true; 21 | private isStart: boolean = false; 22 | private isMoveInited: boolean = false; 23 | private lastClientX: number = 0; 24 | private lastClientY: number = 0; 25 | public constructor({ 26 | paperWrapper, 27 | viewport, 28 | toolOperation, 29 | selection, 30 | }: { 31 | paperWrapper: PaperWrapper; 32 | viewport: Viewport; 33 | toolOperation: ToolOperation; 34 | selection: Selection; 35 | }) { 36 | const paper = paperWrapper.getPaper(); 37 | this.svgDom = paperWrapper.getSvgDom(); 38 | this.multiSelectShape = new MultiSelectShape(paper); 39 | this.viewport = viewport; 40 | this.selection = selection; 41 | this.toolOperation = toolOperation; 42 | 43 | if (isMobile) { 44 | this.svgDom?.addEventListener('touchstart', this.handleTouchstart, false); 45 | } else { 46 | this.svgDom?.addEventListener('mousedown', this.handleMousedown); 47 | this.svgDom?.addEventListener('mousemove', this.handleMousemove); 48 | this.svgDom?.addEventListener('mouseup', this.handleMouseup); 49 | } 50 | } 51 | 52 | public disable(): void { 53 | this.able = false; 54 | } 55 | 56 | public enable(): void { 57 | this.able = true; 58 | } 59 | 60 | public clear(): void { 61 | if (isMobile) { 62 | this.svgDom?.removeEventListener('touchstart', this.handleTouchstart, false); 63 | } else { 64 | this.svgDom?.removeEventListener('mousedown', this.handleMousedown); 65 | this.svgDom?.removeEventListener('mousemove', this.handleMousemove); 66 | this.svgDom?.removeEventListener('mouseup', this.handleMouseup); 67 | } 68 | } 69 | 70 | private handleTouchstart = (): void => { 71 | this.selection.empty(); 72 | } 73 | 74 | private handleMousedown = (event: MouseEvent): void => { 75 | if (!this.able) { 76 | return; 77 | } 78 | 79 | this.isStart = true; 80 | this.lastClientX = event.clientX; 81 | this.lastClientY = event.clientY; 82 | 83 | this.selection.empty(); 84 | } 85 | 86 | private handleMousemove = (event: MouseEvent): void => { 87 | if (!this.isStart) return; 88 | 89 | const { clientX, clientY } = event; 90 | const dx = this.lastClientX - clientX; 91 | const dy = this.lastClientY - clientY; 92 | 93 | if (!this.isMoveInited && (Math.abs(dx) > validDiff || Math.abs(dy) > validDiff)) { 94 | const viewportPosition = this.viewport.getViewportPosition(event.clientX, event.clientY); 95 | this.multiSelectShape.init(viewportPosition.x, viewportPosition.y); 96 | 97 | const nodeMap = this.toolOperation.getNodeMap(); 98 | this.allNodes = Object.keys(nodeMap).map((nodeId) => { 99 | return nodeMap[nodeId]; 100 | }); 101 | 102 | this.isMoveInited = true; 103 | } 104 | 105 | if (this.isMoveInited) { 106 | const viewportPosition = this.viewport.getViewportPosition(event.clientX, event.clientY); 107 | this.multiSelectShape.resize(viewportPosition.x, viewportPosition.y); 108 | 109 | 110 | const intersectNodes = this.allNodes.filter((item) => { 111 | return Raphael.isBBoxIntersect(this.multiSelectShape.getBBox(), item.getBBox()); 112 | }) || []; 113 | 114 | const selectedNodes = []; 115 | 116 | // multi select in order 117 | for (let i = 0; i < this.preSelectNodes.length; i++) { 118 | if (intersectNodes.includes(this.preSelectNodes[i])) { 119 | selectedNodes.push(this.preSelectNodes[i]); 120 | } 121 | } 122 | 123 | for (let i = 0; i < intersectNodes.length; i++) { 124 | if (!selectedNodes.includes(intersectNodes[i])) { 125 | selectedNodes.push(intersectNodes[i]); 126 | } 127 | } 128 | 129 | this.selection.select(selectedNodes); 130 | this.preSelectNodes = selectedNodes; 131 | } 132 | 133 | this.lastClientX = clientX; 134 | this.lastClientY = clientY; 135 | } 136 | 137 | private handleMouseup = (): void => { 138 | if (!this.isStart) return; 139 | 140 | this.multiSelectShape.hide(); 141 | this.allNodes = []; 142 | this.preSelectNodes = []; 143 | this.isStart = false; 144 | this.isMoveInited = false; 145 | this.lastClientX = 0; 146 | this.lastClientY = 0; 147 | } 148 | } 149 | 150 | export default MultiSelect; 151 | -------------------------------------------------------------------------------- /apps/core/src/selection/pre-selection.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockyRen/mindmaptree/b780e8fad8da7490ad4b7b81ced63de3076a8e95/apps/core/src/selection/pre-selection.ts -------------------------------------------------------------------------------- /apps/core/src/selection/selection-arrow-next.ts: -------------------------------------------------------------------------------- 1 | import Node from '../node/node'; 2 | import { Direction } from '../types'; 3 | 4 | export type ArrowType = 'ArrowUp' | 'ArrowRight' | 'ArrowDown' | 'ArrowLeft'; 5 | 6 | class SelectionNext { 7 | public constructor() { } 8 | 9 | public getArrowNextNode(node: Node, arrowType: ArrowType): Node | null { 10 | let nextNode: Node | null = null; 11 | 12 | switch (arrowType) { 13 | case 'ArrowUp': { 14 | nextNode = this.getVeriticalNext(node, true); 15 | break; 16 | } 17 | case 'ArrowRight': { 18 | nextNode = this.getHorizontal(node, Direction.RIGHT); 19 | break; 20 | } 21 | case 'ArrowDown': { 22 | nextNode = this.getVeriticalNext(node, false); 23 | break; 24 | } 25 | case 'ArrowLeft': { 26 | nextNode = this.getHorizontal(node, Direction.LEFT); 27 | break; 28 | } 29 | default: { 30 | break; 31 | } 32 | } 33 | 34 | return nextNode; 35 | } 36 | 37 | private getHorizontal(node: Node, targetDirection: Direction.RIGHT | Direction.LEFT): Node | null { 38 | const isToFather = (node.direction !== Direction.NONE) && (node.direction !== targetDirection) 39 | 40 | if (isToFather) { 41 | return node.father; 42 | } 43 | 44 | const children = node.getDirectionChildren(targetDirection); 45 | const middleIndex = Math.floor((children.length - 1) / 2); 46 | const middleChild = children[middleIndex] || null; 47 | 48 | return middleChild; 49 | } 50 | 51 | private getVeriticalNext(node: Node, isUp: boolean): Node | null { 52 | if (node.isRoot()) { 53 | const rightChildren = node.getDirectionChildren(Direction.RIGHT); 54 | const leftChildren = node.getDirectionChildren(Direction.LEFT); 55 | 56 | const children = rightChildren.length > 0 ? rightChildren : leftChildren; 57 | 58 | const firstChild = children[0] || null; 59 | const lastChild = children[children.length - 1] || null; 60 | 61 | return isUp ? firstChild : lastChild; 62 | } 63 | 64 | let targetDepth = 0; 65 | let curNode: Node | null = node; 66 | let nextNode: Node | null = null; 67 | 68 | while (curNode !== null && !curNode?.isRoot()) { 69 | const nextBrothers = this.getNextBrothers(curNode, isUp); 70 | 71 | for (let i = 0; i < nextBrothers.length; i++) { 72 | const curNextNode = this.findTargetDepthNode(nextBrothers[i], targetDepth, 0, isUp); 73 | if (curNextNode) { 74 | nextNode = curNextNode; 75 | break; 76 | } 77 | } 78 | 79 | if (nextNode) { 80 | break; 81 | } 82 | 83 | curNode = curNode?.father; 84 | targetDepth += 1; 85 | } 86 | 87 | return nextNode; 88 | } 89 | 90 | private findTargetDepthNode(node: Node, targetDepth: number, curDepth: number, isUp: boolean): Node | null { 91 | if (targetDepth === curDepth) { 92 | return node; 93 | } 94 | 95 | let targetNode: Node | null = null; 96 | 97 | let children = node.getDirectionChildren(node.direction); 98 | if (isUp) { 99 | children = children.reverse(); 100 | } 101 | 102 | for (let i = 0; i < children.length; i++) { 103 | const child = children[i]; 104 | const curTargetNode = this.findTargetDepthNode(child, targetDepth, curDepth + 1, isUp); 105 | if (curTargetNode) { 106 | targetNode = curTargetNode; 107 | break; 108 | } 109 | } 110 | 111 | return targetNode; 112 | } 113 | 114 | private getNextBrothers(node: Node, isUp: boolean): Node[] { 115 | const nextBrothers: Node[] = []; 116 | let brothers = node?.father?.getDirectionChildren(node.direction) || []; 117 | if (isUp) { 118 | brothers = brothers.reverse(); 119 | } 120 | 121 | let isFound = false; 122 | for (let i = 0; i < brothers.length; i++) { 123 | if (isFound) { 124 | nextBrothers.push(brothers[i]); 125 | } 126 | if (brothers[i].id === node.id) { 127 | isFound = true; 128 | } 129 | } 130 | 131 | return nextBrothers; 132 | } 133 | } 134 | 135 | export default SelectionNext; 136 | -------------------------------------------------------------------------------- /apps/core/src/selection/selection-boundary-move.ts: -------------------------------------------------------------------------------- 1 | import Viewport from '../viewport'; 2 | import Selection from './selection'; 3 | import Node from '../node/node'; 4 | 5 | class SelectionBoundaryMove { 6 | private selectNodes: Node[]; 7 | public constructor(selection: Selection, viewport: Viewport) { 8 | this.selectNodes = selection.getSelectNodes(); 9 | 10 | selection.on('select', (selectNodes) => { 11 | const oldSelectNodes = this.selectNodes; 12 | this.selectNodes = selectNodes; 13 | const selectNode = selectNodes[0]; 14 | 15 | if (selectNodes.length !== 1 || selectNode.isInvisible()) return; 16 | if (this.selectNodes[0].id && this.selectNodes[0].id === oldSelectNodes[0]?.id) return; 17 | 18 | const nodeBBox = selectNode.getBBox(); 19 | const viewBox = viewport.getViewbox(); 20 | let targetX = viewBox.x; 21 | let targetY = viewBox.y; 22 | 23 | if (nodeBBox.x < viewBox.x) { 24 | targetX = nodeBBox.x - 15; 25 | } else if (nodeBBox.x2 > viewBox.x + viewBox.width) { 26 | targetX = nodeBBox.x2 - viewBox.width + 15; 27 | } 28 | 29 | if (nodeBBox.y < viewBox.y) { 30 | targetY = nodeBBox.y - 15; 31 | } else if (nodeBBox.y2 > viewBox.y + viewBox.height) { 32 | targetY = nodeBBox.y2 - viewBox.height + 15; 33 | } 34 | 35 | if (targetX === viewBox.x && targetY === viewBox.y) return; 36 | 37 | viewport.translateTo(targetX, targetY); 38 | }); 39 | } 40 | } 41 | 42 | export default SelectionBoundaryMove; 43 | -------------------------------------------------------------------------------- /apps/core/src/selection/selection-remove-next.ts: -------------------------------------------------------------------------------- 1 | import Node from '../node/node'; 2 | import { DepthType } from '../helper'; 3 | import { Direction } from '../types'; 4 | 5 | class SelectionRemoveNext { 6 | public constructor() { } 7 | 8 | public getRemoveNextNode(selectNodes: Node[]): Node | null { 9 | if (selectNodes.length === 0) return null; 10 | 11 | for (let i = selectNodes.length - 1; i >= 0; i--) { 12 | const curSelectNode = selectNodes[i]; 13 | const curTopNode = this.getTopNode(curSelectNode, selectNodes); 14 | if (curTopNode.isRoot()) return null; 15 | 16 | const brothers = curTopNode.father!.getDirectionChildren(curTopNode.direction); 17 | const index = brothers.findIndex((curNode) => curNode.id === curTopNode.id); 18 | 19 | let upIndex = index - 1; 20 | let upNode = brothers[upIndex]; 21 | while (selectNodes.includes(upNode) && !!upNode) { 22 | upIndex -= 1; 23 | upNode = brothers[upIndex]; 24 | } 25 | if (upNode) return upNode; 26 | 27 | let downIndex = index + 1; 28 | let downNode = brothers[downIndex]; 29 | while (selectNodes.includes(downNode) && !!downNode) { 30 | downIndex += 1; 31 | downNode = brothers[downIndex]; 32 | } 33 | if (downNode) return downNode; 34 | 35 | if (curTopNode.getDepthType() === DepthType.firstLevel) { 36 | const oppositeDirection = curTopNode.direction === Direction.RIGHT ? Direction.LEFT : Direction.RIGHT; 37 | const oppositeBrothers = curTopNode.father!.getDirectionChildren(oppositeDirection); 38 | const lastOppositeBrother = oppositeBrothers[oppositeBrothers.length - 1]; 39 | if (lastOppositeBrother && !selectNodes.includes(lastOppositeBrother)) return lastOppositeBrother; 40 | } 41 | 42 | const curTopFatherNode = curTopNode.father!; 43 | if (!curTopFatherNode.isRoot()) { 44 | return curTopFatherNode; 45 | } 46 | } 47 | 48 | return null; 49 | } 50 | 51 | private getTopNode(node: Node, selectNodes: Node[]): Node { 52 | let topNode = node; 53 | let curNode: Node | null = node; 54 | 55 | while (curNode !== null) { 56 | if (selectNodes.includes(curNode)) { 57 | topNode = curNode; 58 | } 59 | curNode = curNode.father; 60 | } 61 | 62 | return topNode; 63 | } 64 | } 65 | 66 | export default SelectionRemoveNext; 67 | -------------------------------------------------------------------------------- /apps/core/src/selection/selection.ts: -------------------------------------------------------------------------------- 1 | import Node from '../node/node'; 2 | import EventEmitter from 'eventemitter3'; 3 | import SelectionArrowNext from './selection-arrow-next'; 4 | import SelectionRemoveNext from './selection-remove-next'; 5 | import type { ArrowType } from './selection-arrow-next'; 6 | 7 | interface SelectionEventMap { 8 | 'select': (nodes: Node[]) => void; 9 | } 10 | 11 | class Selection { 12 | public selectNodes: Node[] = []; 13 | private readonly selectionArrowNext: SelectionArrowNext; 14 | private readonly selectionRemoveNext: SelectionRemoveNext; 15 | private readonly eventEmitter: EventEmitter; 16 | private isMultiClickMode: boolean = false; 17 | public constructor(private root: Node) { 18 | this.selectionArrowNext = new SelectionArrowNext(); 19 | this.selectionRemoveNext = new SelectionRemoveNext(); 20 | this.eventEmitter = new EventEmitter(); 21 | } 22 | 23 | public setIsMultiClickMode(isMultiClickMode: boolean) { 24 | this.isMultiClickMode = isMultiClickMode; 25 | } 26 | 27 | public select(nodes: Node[]): void { 28 | const clonedNodes = [...nodes]; 29 | this.selectNodes.forEach((selection) => selection.setStyle('base')); 30 | clonedNodes.forEach((node) => node.setStyle('select')); 31 | this.selectNodes = clonedNodes; 32 | 33 | this.eventEmitter.emit('select', this.selectNodes); 34 | } 35 | 36 | public selectSingle(node: Node): void { 37 | if (!this.isMultiClickMode) { 38 | this.select([node]); 39 | return; 40 | } 41 | 42 | const targetNodeIndex = this.selectNodes.findIndex((selectNode) => selectNode.id === node.id); 43 | if (targetNodeIndex >= 0) { 44 | node.setStyle('base'); 45 | this.selectNodes.splice(targetNodeIndex, 1); 46 | } else { 47 | node.setStyle('select'); 48 | this.selectNodes.push(node); 49 | } 50 | this.eventEmitter.emit('select', this.selectNodes); 51 | } 52 | 53 | public selectArrowNext(arrowType: ArrowType): void { 54 | const lastSelectNode = this.selectNodes[this.selectNodes.length - 1]; 55 | if (!lastSelectNode) return; 56 | 57 | const nextNode = this.selectionArrowNext.getArrowNextNode(lastSelectNode, arrowType); 58 | if (nextNode) { 59 | this.select([nextNode]); 60 | } 61 | } 62 | 63 | public empty(): void { 64 | this.select([]); 65 | } 66 | 67 | public on>( 68 | eventName: T, 69 | callback: EventEmitter.EventListener 70 | ) { 71 | this.eventEmitter.on(eventName, callback); 72 | } 73 | 74 | public getSelectNodes(): Node[] { 75 | return this.selectNodes; 76 | } 77 | 78 | public getSingleSelectNode(): Node | null { 79 | if (this.selectNodes.length !== 1) { 80 | return null; 81 | } 82 | return this.selectNodes[0]; 83 | } 84 | 85 | public getRemoveNextNode(): Node | null { 86 | return this.selectionRemoveNext.getRemoveNextNode(this.selectNodes); 87 | } 88 | 89 | public selectByIds(selectIds: string[]) { 90 | const nodeMap = this.getNodeMap(); 91 | const selectNodes = selectIds.map((nodeId) => nodeMap[nodeId]) 92 | .filter((node) => !!node) || []; 93 | 94 | this.select(selectNodes); 95 | } 96 | 97 | // todo 抽象 98 | private getNodeMap = (): Record => { 99 | const nodeMap: Record = {}; 100 | this.getNodeMapInner(this.root, nodeMap); 101 | return nodeMap; 102 | } 103 | 104 | private getNodeMapInner = (node: Node, nodeMap: Record): void => { 105 | if (!node) return; 106 | nodeMap[node.id] = node; 107 | 108 | node.children?.forEach((child) => { 109 | this.getNodeMapInner(child, nodeMap) !== null; 110 | }); 111 | } 112 | } 113 | 114 | export default Selection; 115 | -------------------------------------------------------------------------------- /apps/core/src/shape/collaborate-shape.ts: -------------------------------------------------------------------------------- 1 | import type { RaphaelPaper, RaphaelElement, RaphaelSet, RaphaelAxisAlignedBoundingBox } from 'raphael'; 2 | 3 | const paddingWidth = 20; 4 | const paddingHeight = 12; 5 | const borderPadding = 6; 6 | const borderWidth = 2; 7 | 8 | class CollaborateShape { 9 | private readonly labelShape: RaphaelElement; 10 | private readonly rectShape: RaphaelElement; 11 | private readonly borderShape: RaphaelElement; 12 | private readonly shapeSet: RaphaelSet; 13 | public constructor({ 14 | paper, 15 | nodeBBox, 16 | name, 17 | color, 18 | }: { 19 | paper: RaphaelPaper; 20 | nodeBBox: RaphaelAxisAlignedBoundingBox; 21 | name: string; 22 | color: string; 23 | }) { 24 | this.rectShape = paper.rect(nodeBBox.x, nodeBBox.y, 0, 0); 25 | this.labelShape = paper.text(nodeBBox.x, nodeBBox.y, name); 26 | this.borderShape = paper.rect(nodeBBox.x, nodeBBox.y, 0, 0, 4); 27 | this.shapeSet = paper.set().push(this.labelShape, this.rectShape, this.borderShape); 28 | this.shapeSet.toBack(); 29 | 30 | this.labelShape.attr({ 31 | 'stroke': '#fff', 32 | }); 33 | 34 | this.rectShape.attr({ 35 | 'fill': color, 36 | 'stroke-opacity': 0, 37 | }); 38 | 39 | this.borderShape.attr({ 40 | 'stroke': color, 41 | 'stroke-width': borderWidth, 42 | 'fill-opacity': 0, 43 | }); 44 | 45 | this.setPosition(nodeBBox); 46 | } 47 | 48 | public remove(): void { 49 | this.shapeSet.remove(); 50 | } 51 | 52 | public setPosition(nodeBBox: RaphaelAxisAlignedBoundingBox): void { 53 | const labelBBox = this.labelShape.getBBox(); 54 | 55 | const rectWidth = labelBBox.width + paddingWidth; 56 | const rectHeight = labelBBox.height + paddingHeight; 57 | 58 | this.rectShape.attr({ 59 | width: rectWidth, 60 | height: rectHeight, 61 | }); 62 | 63 | this.borderShape.attr({ 64 | width: nodeBBox.width + borderPadding, 65 | height: nodeBBox.height + borderPadding, 66 | }); 67 | 68 | const rectBBox = this.rectShape.getBBox(); 69 | const rectX = nodeBBox.x - borderPadding / 2; 70 | const rectY = nodeBBox.y - rectBBox.height - borderPadding / 2; 71 | 72 | const labelX = rectX + (rectBBox.width - labelBBox.width) / 2; 73 | const labelY = rectY + (rectBBox.height - labelBBox.height) / 2; 74 | 75 | const borderX = nodeBBox.x - borderPadding / 2; 76 | const borderY = nodeBBox.y - borderPadding / 2; 77 | 78 | this.shapeTranslateTo(this.labelShape, labelX, labelY); 79 | this.shapeTranslateTo(this.rectShape, rectX, rectY); 80 | this.shapeTranslateTo(this.borderShape, borderX, borderY); 81 | } 82 | 83 | private shapeTranslateTo(shape: RaphaelElement | RaphaelSet, x: number, y: number): void { 84 | const { x: oldX, y: oldY } = shape.getBBox(); 85 | const dx = x - oldX; 86 | const dy = y - oldY; 87 | 88 | if (dx === 0 && dy === 0) return; 89 | 90 | shape.translate(dx, dy); 91 | } 92 | } 93 | 94 | export default CollaborateShape; 95 | -------------------------------------------------------------------------------- /apps/core/src/shape/common/draw-edge.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from '../../types'; 2 | import { expanderBoxWidth } from '../expander-shape'; 3 | import type { RaphaelPaper, RaphaelAxisAlignedBoundingBox, RaphaelElement } from 'raphael'; 4 | 5 | export const drawFirstEdge = ({ 6 | paper, 7 | sourceBBox, 8 | targetBBox, 9 | direction, 10 | }: { 11 | paper: RaphaelPaper; 12 | sourceBBox: RaphaelAxisAlignedBoundingBox; 13 | targetBBox: RaphaelAxisAlignedBoundingBox; 14 | direction: Direction; 15 | }): RaphaelElement => { 16 | // start 17 | const x1 = sourceBBox.cx; 18 | const y1 = sourceBBox.cy; 19 | // end 20 | const x2 = direction === Direction.LEFT ? targetBBox.x2 : targetBBox.x; 21 | const y2 = targetBBox.cy; 22 | 23 | const k1 = 0.8; 24 | const k2 = 0.2; 25 | // 贝塞尔曲线控制点 26 | const x3 = x2 - k1 * (x2 - x1); 27 | const y3 = y2 - k2 * (y2 - y1); 28 | 29 | return paper.path(`M${x1} ${y1}Q${x3} ${y3} ${x2} ${y2}`); 30 | }; 31 | 32 | export const drawGrandChildEdge = ({ 33 | paper, 34 | sourceBBox, 35 | targetBBox, 36 | direction, 37 | targetDepth, 38 | hasUnder = false, 39 | }: { 40 | paper: RaphaelPaper; 41 | sourceBBox: RaphaelAxisAlignedBoundingBox; 42 | targetBBox: RaphaelAxisAlignedBoundingBox; 43 | direction: Direction; 44 | targetDepth: number; 45 | hasUnder?: boolean; 46 | }): RaphaelElement => { 47 | let shortX = 0; 48 | let shortY = 0; 49 | let connectX = 0; 50 | let connectY = 0; 51 | let targetX = 0; 52 | let targetY = 0; 53 | let targetUnderEndX = 0; 54 | let targetUnderEndY = 0; 55 | 56 | if (direction === Direction.RIGHT) { 57 | shortX = sourceBBox.x2; 58 | connectX = shortX + expanderBoxWidth; 59 | targetX = targetBBox.x; 60 | targetUnderEndX = targetBBox.x2; 61 | } else { 62 | shortX = sourceBBox.x; 63 | connectX = shortX - expanderBoxWidth; 64 | targetX = targetBBox.x2; 65 | targetUnderEndX = targetBBox.x; 66 | } 67 | 68 | if (targetDepth === 2) { 69 | shortY = sourceBBox.cy; 70 | } else { 71 | shortY = sourceBBox.y2; 72 | } 73 | 74 | connectY = shortY; 75 | targetY = hasUnder ? targetBBox.y2 : targetBBox.cy; 76 | targetUnderEndY = targetY; 77 | 78 | const connectPathStr = createConnectPathStr(connectX, connectY, targetX, targetY); 79 | let pathStr = `M${shortX} ${shortY} L${connectX} ${connectY} ${connectPathStr}`; 80 | 81 | if (hasUnder) { 82 | pathStr += ` M ${targetX} ${targetY} L${targetUnderEndX} ${targetUnderEndY}`; 83 | } 84 | 85 | return paper.path(pathStr); 86 | }; 87 | 88 | const createConnectPathStr = (x1: number, y1: number, x2: number, y2: number): string => { 89 | const control1XFactor = 0.3; 90 | const control1YFactor = 0.76; 91 | const control1X = x1 + control1XFactor * (x2 - x1); 92 | const control1Y = y1 + control1YFactor * (y2 - y1); 93 | 94 | const control2XFactor = 0.5; 95 | const control2YFactor = 0; 96 | const control2X = x2 - control2XFactor * (x2 - x1); 97 | const control2Y = y2 - control2YFactor * (y2 - y1); 98 | 99 | return `M${x1} ${y1}C${control1X} ${control1Y} ${control2X} ${control2Y} ${x2} ${y2}`; 100 | }; 101 | -------------------------------------------------------------------------------- /apps/core/src/shape/common/node-shape-style.ts: -------------------------------------------------------------------------------- 1 | import { RaphaelSet, RaphaelElement, RaphaelAttributes } from 'raphael'; 2 | 3 | const labelDefaultAttr: Partial = { 4 | 'font-size': 16, 5 | 'fill': '#000', 6 | 'opacity': 1, 7 | }; 8 | 9 | const rectDefaultAttr: Partial = { 10 | 'fill-opacity': 0, 11 | 'stroke': '#808080', 12 | 'stroke-opacity': 1, 13 | 'opacity': 1, 14 | }; 15 | 16 | const borderDefaultAttr: Partial = { 17 | 'stroke': '#fff', 18 | 'stroke-width': 2, 19 | 'fill': '#fff', 20 | 'fill-opacity': 0, 21 | 'opacity': 0, 22 | }; 23 | 24 | export type StyleType = 'select' | 'overlay' | 'disable' | 'base' | 'hover'; 25 | 26 | class NodeShapeStyle { 27 | private readonly shapeSet: RaphaelSet; 28 | private readonly borderShape: RaphaelElement; 29 | private readonly labelShape: RaphaelElement; 30 | private readonly rectShape: RaphaelElement; 31 | private readonly labelBaseAttr: Partial; 32 | private readonly rectBaseAttr: Partial; 33 | private readonly borderBaseAttr: Partial; 34 | private collaborateStyle: { name: string; color: string; } | null = null; 35 | private styleType: StyleType = 'base'; 36 | public constructor({ 37 | shapeSet, 38 | labelShape, 39 | borderShape, 40 | rectShape, 41 | labelBaseAttr, 42 | rectBaseAttr, 43 | borderBaseAttr, 44 | }: { 45 | shapeSet: RaphaelSet; 46 | borderShape: RaphaelElement; 47 | labelShape: RaphaelElement; 48 | rectShape: RaphaelElement; 49 | labelBaseAttr?: Partial; 50 | borderBaseAttr?: Partial; 51 | rectBaseAttr?: Partial; 52 | }) { 53 | this.shapeSet = shapeSet; 54 | this.labelShape = labelShape; 55 | this.borderShape = borderShape; 56 | this.rectShape = rectShape; 57 | this.labelBaseAttr = { ...labelDefaultAttr, ...labelBaseAttr, }; 58 | this.rectBaseAttr = { ...rectDefaultAttr, ...rectBaseAttr, }; 59 | this.borderBaseAttr = { ...borderDefaultAttr, ...borderBaseAttr, }; 60 | } 61 | 62 | public setBaseStyle(): void { 63 | this.labelShape.attr(this.labelBaseAttr); 64 | this.borderShape.attr(this.borderBaseAttr); 65 | this.rectShape.attr(this.rectBaseAttr); 66 | } 67 | 68 | public setStyle(styleType: StyleType): void { 69 | switch (styleType) { 70 | case 'select': { 71 | this.setBaseStyle(); 72 | this.borderShape.attr({ 73 | 'stroke': '#3498DB', 74 | 'opacity': 1, 75 | }); 76 | break; 77 | } 78 | case 'overlay': { 79 | this.setBaseStyle(); 80 | this.borderShape.attr({ 81 | 'stroke': '#E74C3C', 82 | 'opacity': 0.8, 83 | }); 84 | break; 85 | } 86 | case 'disable': { 87 | this.shapeSet.attr({ 88 | opacity: 0.4, 89 | }); 90 | break; 91 | } 92 | case 'hover': { 93 | this.setBaseStyle(); 94 | this.borderShape.attr({ 95 | 'stroke': '#3498DB', 96 | 'opacity': 0.5, 97 | }); 98 | break; 99 | } 100 | case 'base': 101 | default: { 102 | this.setBaseStyle(); 103 | break; 104 | } 105 | } 106 | 107 | this.styleType = styleType; 108 | } 109 | 110 | public getStyle(): StyleType { 111 | return this.styleType; 112 | } 113 | 114 | public getBaseAttr(): { 115 | labelBaseAttr: Partial; 116 | borderBaseAttr: Partial; 117 | rectBaseAttr: Partial; 118 | } { 119 | return { 120 | labelBaseAttr: this.labelBaseAttr, 121 | borderBaseAttr: this.borderBaseAttr, 122 | rectBaseAttr: this.rectBaseAttr, 123 | }; 124 | } 125 | } 126 | 127 | export default NodeShapeStyle; 128 | -------------------------------------------------------------------------------- /apps/core/src/shape/common/shape-event-emitter.ts: -------------------------------------------------------------------------------- 1 | import { RaphaelSet, RaphaelElement, RaphaelBaseElement } from 'raphael'; 2 | 3 | export type EventNames = 'mousedown' | 'click' | 'dblclick' | 'drag' | 'hover' | 'touchstart'; 4 | export type EventArgs = Parameters; 5 | 6 | type EventArgsMap = Partial<{ 7 | [EventName in EventNames]: EventArgs[]; 8 | }>; 9 | 10 | class ShapeEventEmitter { 11 | private readonly eventArgs: EventArgsMap = {}; 12 | public constructor(private readonly shape: RaphaelElement | RaphaelSet) { } 13 | 14 | public on(eventName: T, ...args: EventArgs): void { 15 | if (!eventName) return; 16 | 17 | if (this.eventArgs[eventName] === undefined) { 18 | this.eventArgs[eventName] = []; 19 | } 20 | 21 | this.eventArgs[eventName]!.push(args); 22 | 23 | // @ts-ignore 24 | this.shape[eventName](...args); 25 | } 26 | 27 | public removeAllListeners(): void { 28 | const eventNames: EventNames[] = Object.keys(this.eventArgs) as EventNames[]; 29 | eventNames.forEach((eventName: EventNames) => { 30 | const events = this.eventArgs[eventName]; 31 | 32 | events?.forEach((args) => { 33 | // @ts-ignore 34 | this.shape[`un${eventName}`](...args); 35 | }); 36 | }) 37 | } 38 | } 39 | 40 | export default ShapeEventEmitter; 41 | -------------------------------------------------------------------------------- /apps/core/src/shape/drag-temp-node-shape.ts: -------------------------------------------------------------------------------- 1 | import { RaphaelAxisAlignedBoundingBox, RaphaelElement, RaphaelPaper, RaphaelSet } from "raphael"; 2 | import { Direction } from "../types"; 3 | import { getNodeXGap } from './gap'; 4 | import { drawFirstEdge, drawGrandChildEdge } from './common/draw-edge'; 5 | 6 | interface DragTempNodeShapeOptions { 7 | paper: RaphaelPaper; 8 | sourceBBox: RaphaelAxisAlignedBoundingBox; 9 | targetBBox1: RaphaelAxisAlignedBoundingBox | null; 10 | targetBBox2: RaphaelAxisAlignedBoundingBox | null; 11 | targetDepth: number; 12 | direction: Direction; 13 | } 14 | 15 | const firstBoundaryOffset = 55; 16 | const boundaryYOffset = 14; 17 | const rectWidth = 50; 18 | const rectHeight = 13; 19 | const grandchildYOffset = 3; 20 | 21 | class DragTempNodeShape { 22 | private readonly paper: RaphaelPaper; 23 | private readonly shapeSet: RaphaelSet; 24 | 25 | public constructor({ 26 | paper, 27 | sourceBBox, 28 | targetBBox1, 29 | targetBBox2, 30 | targetDepth, 31 | direction, 32 | }: DragTempNodeShapeOptions) { 33 | this.paper = paper; 34 | 35 | const validTargetBBox = targetBBox1 || targetBBox2; 36 | 37 | const yOffset = targetDepth === 1 ? firstBoundaryOffset : boundaryYOffset; 38 | 39 | let x = 0; 40 | 41 | if (validTargetBBox !== null) { 42 | x = direction === Direction.RIGHT ? validTargetBBox.x : (validTargetBBox.x2 - rectWidth); 43 | } else { 44 | const xGap = getNodeXGap(targetDepth); 45 | x = direction === Direction.RIGHT ? (sourceBBox.x2 + xGap) : (sourceBBox.x - xGap - rectWidth); 46 | } 47 | 48 | let cy = 0; 49 | if (targetBBox1 !== null && targetBBox2 !== null) { 50 | cy = (targetBBox2.y + targetBBox1.y2) / 2; 51 | } else if (targetBBox1 !== null && targetBBox2 === null) { 52 | cy = targetBBox1.y2 + yOffset; 53 | 54 | if (targetDepth > 1) cy += grandchildYOffset; 55 | } else if (targetBBox1 === null && targetBBox2 !== null) { 56 | cy = targetBBox2.y - yOffset; 57 | 58 | if (targetDepth > 1) cy += grandchildYOffset; 59 | } else { 60 | cy = sourceBBox.cy; 61 | } 62 | 63 | const y = cy - rectHeight / 2; 64 | 65 | const rectShape = this.drawRect({ x, y }); 66 | const edgeShape = this.drawPath({ 67 | sourceBBox, 68 | targetBBox: rectShape.getBBox(), 69 | targetDepth, 70 | direction, 71 | }); 72 | 73 | this.shapeSet = paper.set().push(rectShape).push(edgeShape); 74 | } 75 | 76 | public remove(): void { 77 | this.shapeSet.remove(); 78 | } 79 | 80 | private drawRect({ x, y }: { x: number; y: number; }) { 81 | const rectShape = this.paper.rect(x, y, rectWidth, rectHeight, 6); 82 | rectShape.attr({ 83 | 'fill': '#E74C3C', 84 | 'stroke-opacity': 0, 85 | 'opacity': 0.8, 86 | }); 87 | return rectShape; 88 | } 89 | 90 | private drawPath({ 91 | sourceBBox, 92 | targetBBox, 93 | targetDepth, 94 | direction, 95 | }: { 96 | sourceBBox: RaphaelAxisAlignedBoundingBox; 97 | targetBBox: RaphaelAxisAlignedBoundingBox; 98 | targetDepth: number; 99 | direction: Direction; 100 | }): RaphaelElement { 101 | const edgeShape = targetDepth === 1 ? 102 | drawFirstEdge({ 103 | paper: this.paper, 104 | sourceBBox, 105 | targetBBox, 106 | direction, 107 | }) : 108 | drawGrandChildEdge({ 109 | paper: this.paper, 110 | sourceBBox, 111 | targetBBox, 112 | direction, 113 | targetDepth, 114 | }); 115 | 116 | edgeShape.attr({ 117 | stroke: '#E74C3C', 118 | 'stroke-width': 2, 119 | opacity: 0.6, 120 | }); 121 | 122 | return edgeShape; 123 | } 124 | } 125 | 126 | export default DragTempNodeShape; 127 | -------------------------------------------------------------------------------- /apps/core/src/shape/expander-shape.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from '../types'; 2 | import ShapeEventEmitter from './common/shape-event-emitter'; 3 | import { isMobile } from '../helper'; 4 | import type { RaphaelPaper, RaphaelAxisAlignedBoundingBox, RaphaelSet, RaphaelElement } from 'raphael'; 5 | import type { EventNames, EventArgs } from './common/shape-event-emitter'; 6 | 7 | const circleRadius = isMobile ? 7 : 5; 8 | const operationWidth = circleRadius * 0.6; 9 | const circlePositionOffset = isMobile ? 3 : 2; 10 | 11 | export const expanderBoxWidth = circleRadius * 2 + circlePositionOffset + 3; 12 | 13 | class ExpanderShape { 14 | private readonly paper: RaphaelPaper; 15 | private readonly circleShape: RaphaelElement; 16 | private readonly horizontalShape: RaphaelElement; 17 | private readonly verticalShape: RaphaelElement; 18 | private readonly shapeSet: RaphaelSet; 19 | private readonly shapeEventEmitter: ShapeEventEmitter; 20 | private isExpand: boolean; 21 | public constructor({ 22 | paper, 23 | nodeBBox, 24 | isExpand, 25 | direction, 26 | }: { 27 | paper: RaphaelPaper; 28 | nodeBBox: RaphaelAxisAlignedBoundingBox; 29 | isExpand: boolean; 30 | direction: Direction; 31 | }) { 32 | this.paper = paper; 33 | this.isExpand = isExpand; 34 | this.shapeSet = paper.set(); 35 | 36 | const { x, y } = this.getPosition(nodeBBox, direction); 37 | 38 | this.circleShape = paper.circle(x, y, circleRadius); 39 | this.circleShape.attr({ 40 | 'fill': '#fff', 41 | 'fill-opacity': 1, 42 | }); 43 | 44 | // @ts-ignore 45 | this.circleShape.node.style['cursor'] = 'pointer'; 46 | 47 | const { 48 | cx: circleCx, 49 | cy: circleCy, 50 | } = this.circleShape.getBBox(); 51 | 52 | this.horizontalShape = paper.path([ 53 | `M${circleCx - operationWidth} ${circleCy}`, 54 | `L${circleCx + operationWidth} ${circleCy}`, 55 | ].join(' ')); 56 | 57 | this.verticalShape = this.paper.path([ 58 | `M${circleCx} ${circleCy - operationWidth}`, 59 | `L${circleCx} ${circleCy + operationWidth}`, 60 | ].join(' ')); 61 | 62 | 63 | this.shapeSet.push(this.circleShape).push(this.horizontalShape).push(this.verticalShape); 64 | 65 | if (isExpand) { 66 | this.verticalShape.hide(); 67 | } 68 | 69 | this.shapeSet.attr({ 70 | 'stroke': '#000', 71 | 'stroke-opacity': 0.7, 72 | }); 73 | this.shapeSet.toFront(); 74 | 75 | this.shapeEventEmitter = new ShapeEventEmitter(this.shapeSet); 76 | 77 | this.initHover(); 78 | } 79 | 80 | public on(eventName: EventNames, ...args: EventArgs): void { 81 | this.shapeEventEmitter.on(eventName, ...args); 82 | } 83 | 84 | public changeExpand(newIsExpand: boolean): void { 85 | if (this.isExpand === newIsExpand) return; 86 | 87 | if (newIsExpand) { 88 | this.verticalShape.hide(); 89 | } else { 90 | this.verticalShape.show(); 91 | } 92 | 93 | this.isExpand = newIsExpand; 94 | } 95 | 96 | public translateTo(nodeBBox: RaphaelAxisAlignedBoundingBox, direction: Direction) { 97 | const { x, y } = this.getPosition(nodeBBox, direction); 98 | 99 | const { 100 | cx: oldX, 101 | cy: oldY, 102 | } = this.getBBox(); 103 | 104 | const dx = x - oldX; 105 | const dy = y - oldY; 106 | 107 | if (dx === 0 && dy === 0) { 108 | return; 109 | } 110 | 111 | this.shapeSet.translate(dx, dy); 112 | } 113 | 114 | public getBBox(): RaphaelAxisAlignedBoundingBox { 115 | return this.circleShape.getBBox(); 116 | } 117 | 118 | public remove(): void { 119 | this.shapeSet.remove(); 120 | this.shapeEventEmitter.removeAllListeners(); 121 | } 122 | 123 | public setStyle(styleType: 'disable' | 'base' | 'hover'): void { 124 | switch(styleType) { 125 | case 'disable': { 126 | this.setStyle('base'); 127 | this.shapeSet.attr({ 128 | 'opacity': 0.4, 129 | }); 130 | break; 131 | } 132 | case 'hover': { 133 | this.setStyle('base'); 134 | this.circleShape.attr({ 135 | 'fill': '#E7E7E7', 136 | 'fill-opacity': 1, 137 | }); 138 | break; 139 | } 140 | case 'base': 141 | default: { 142 | this.shapeSet.attr({ 143 | 'opacity': 1, 144 | }); 145 | this.circleShape.attr({ 146 | 'fill': '#fff', 147 | 'fill-opacity': 1, 148 | }); 149 | break; 150 | } 151 | } 152 | } 153 | 154 | private getPosition(nodeBBox: RaphaelAxisAlignedBoundingBox, direction: Direction): { 155 | x: number; 156 | y: number; 157 | } { 158 | let x = 0; 159 | const y = nodeBBox.cy; 160 | if (direction === Direction.RIGHT) { 161 | x = nodeBBox.x2 + circleRadius + circlePositionOffset; 162 | } else { 163 | x = nodeBBox.x - circleRadius - circlePositionOffset; 164 | } 165 | 166 | return { 167 | x, 168 | y, 169 | }; 170 | } 171 | 172 | private initHover(): void { 173 | this.shapeEventEmitter.on('hover', () => { 174 | this.setStyle('hover'); 175 | }, () => { 176 | this.setStyle('base'); 177 | }); 178 | } 179 | } 180 | 181 | export default ExpanderShape; 182 | -------------------------------------------------------------------------------- /apps/core/src/shape/first-edge-shape.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from '../types'; 2 | import { drawFirstEdge } from './common/draw-edge'; 3 | import type { RaphaelPaper, RaphaelAxisAlignedBoundingBox, RaphaelElement } from 'raphael'; 4 | 5 | interface FirstEdgeShapeOptions { 6 | paper: RaphaelPaper; 7 | sourceBBox: RaphaelAxisAlignedBoundingBox; 8 | targetBBox: RaphaelAxisAlignedBoundingBox; 9 | direction: Direction; 10 | } 11 | 12 | export class FirstEdgeShape { 13 | private readonly shape: RaphaelElement; 14 | public constructor({ 15 | paper, 16 | sourceBBox, 17 | targetBBox, 18 | direction 19 | }: FirstEdgeShapeOptions) { 20 | this.shape = drawFirstEdge({ 21 | paper, 22 | sourceBBox, 23 | targetBBox, 24 | direction 25 | }); 26 | this.shape.attr({ 27 | 'stroke': '#000', 28 | 'stroke-width': 2, 29 | }); 30 | this.shape.toBack(); 31 | } 32 | 33 | public setStyle(styleType: 'disable' | 'base'): void { 34 | switch(styleType) { 35 | case 'disable': { 36 | this.shape.attr({ 37 | 'opacity': 0.4, 38 | }); 39 | break; 40 | } 41 | case 'base': 42 | default: { 43 | this.shape.attr({ 44 | 'opacity': 1, 45 | }); 46 | break; 47 | } 48 | } 49 | } 50 | 51 | public remove(): void { 52 | this.shape.remove(); 53 | } 54 | } 55 | 56 | export function createFirstEdgeShape(options: FirstEdgeShapeOptions): FirstEdgeShape { 57 | return new FirstEdgeShape(options); 58 | } 59 | -------------------------------------------------------------------------------- /apps/core/src/shape/first-node-shape.ts: -------------------------------------------------------------------------------- 1 | import NodeShape from './node-shape'; 2 | import type { NodeShapeOptions } from './node-shape'; 3 | 4 | export const fontSize = 16; 5 | const paddingWidth = 40; 6 | export const rectHeight = 37; 7 | 8 | export function createFirstNodeShape(options: NodeShapeOptions): NodeShape { 9 | return new NodeShape({ 10 | ...options, 11 | labelBaseAttr: { 12 | 'font-size': fontSize, 13 | }, 14 | rectBaseAttr: { 15 | 'fill': '#eee', 16 | 'fill-opacity': 1, 17 | 'stroke': '#808080', 18 | 'stroke-opacity': 0, 19 | }, 20 | paddingWidth, 21 | rectHeight, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /apps/core/src/shape/gap.ts: -------------------------------------------------------------------------------- 1 | import { DepthType, getDepthType } from '../helper'; 2 | 3 | const firstLevelXGap = 40; 4 | const firstLevelYGap = 25; 5 | 6 | const grandchildXGap = 24; 7 | const grandchildYGap = 15; 8 | 9 | export function getNodeYGap(depth: number): number { 10 | return getDepthType(depth) === DepthType.firstLevel ? firstLevelYGap : grandchildYGap; 11 | } 12 | 13 | export function getNodeXGap(depth: number): number { 14 | return getDepthType(depth) === DepthType.firstLevel ? firstLevelXGap : grandchildXGap; 15 | } 16 | -------------------------------------------------------------------------------- /apps/core/src/shape/grandchild-edge-shape.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from '../types'; 2 | import { drawGrandChildEdge } from './common/draw-edge'; 3 | import type { RaphaelPaper, RaphaelAxisAlignedBoundingBox, RaphaelElement } from 'raphael'; 4 | 5 | interface GrandchildEdgeShapeOptions { 6 | paper: RaphaelPaper; 7 | sourceBBox: RaphaelAxisAlignedBoundingBox; 8 | targetBBox: RaphaelAxisAlignedBoundingBox; 9 | direction: Direction; 10 | targetDepth: number; 11 | } 12 | 13 | export class GrandchildEdgeShape { 14 | private readonly shape: RaphaelElement; 15 | public constructor({ 16 | paper, 17 | sourceBBox, 18 | targetBBox, 19 | direction, 20 | targetDepth, 21 | }: GrandchildEdgeShapeOptions) { 22 | this.shape = drawGrandChildEdge({ 23 | paper, 24 | sourceBBox, 25 | targetBBox, 26 | direction, 27 | targetDepth, 28 | hasUnder: true, 29 | }); 30 | 31 | this.shape.attr({ 32 | 'stroke': '#000', 33 | 'stroke-width': 1.5, 34 | }); 35 | 36 | this.shape.toBack(); 37 | } 38 | 39 | public setStyle(styleType: 'disable' | 'base'): void { 40 | switch(styleType) { 41 | case 'disable': { 42 | this.shape.attr({ 43 | 'opacity': 0.4, 44 | }); 45 | break; 46 | } 47 | case 'base': 48 | default: { 49 | this.shape.attr({ 50 | 'opacity': 1, 51 | }); 52 | break; 53 | } 54 | } 55 | } 56 | 57 | public remove(): void { 58 | this.shape.remove(); 59 | } 60 | } 61 | 62 | export function createGrandchildEdgeShape(options: GrandchildEdgeShapeOptions) { 63 | return new GrandchildEdgeShape(options); 64 | } 65 | -------------------------------------------------------------------------------- /apps/core/src/shape/grandchild-node-shape.ts: -------------------------------------------------------------------------------- 1 | import NodeShape from './node-shape'; 2 | import type { NodeShapeOptions } from './node-shape'; 3 | 4 | export const fontSize = 13; 5 | const paddingWidth = 20; 6 | export const rectHeight = 21; 7 | 8 | export function createGrandchildNodeShape(options: NodeShapeOptions): NodeShape { 9 | return new NodeShape({ 10 | ...options, 11 | labelBaseAttr: { 12 | 'font-size': fontSize, 13 | }, 14 | rectBaseAttr: { 15 | 'stroke-opacity': 0, 16 | }, 17 | paddingWidth, 18 | rectHeight, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /apps/core/src/shape/multi-select-shape.ts: -------------------------------------------------------------------------------- 1 | import type { RaphaelPaper, RaphaelElement, RaphaelAxisAlignedBoundingBox } from 'raphael'; 2 | 3 | class MultiSelectShape { 4 | private startX: number = 0; 5 | private startY: number = 0; 6 | private isInit: boolean = false; 7 | private rectShape: RaphaelElement | null = null; 8 | public constructor(private readonly paper: RaphaelPaper) {} 9 | 10 | public init(startX: number, startY: number) { 11 | this.startX = startX; 12 | this.startY = startY; 13 | this.isInit = true; 14 | } 15 | 16 | public resize(endX: number, endY: number) { 17 | if (!this.isInit) return; 18 | 19 | const { startX, startY } = this; 20 | 21 | const x = startX < endX ? startX : endX; 22 | const y = startY < endY ? startY : endY; 23 | const width = Math.abs(startX - endX); 24 | const height = Math.abs(startY - endY); 25 | 26 | if (this.rectShape === null) { 27 | this.rectShape = this.paper.rect(x, y, width, height); 28 | this.rectShape.attr({ 29 | stroke: '#73a1bf', 30 | fill: 'rgba(153,124,255,0.1)', 31 | opacity: 0.8, 32 | }) 33 | } else { 34 | this.rectShape.attr({ x, y, width, height }); 35 | } 36 | } 37 | 38 | public hide() { 39 | this.isInit = false; 40 | this.rectShape?.remove(); 41 | this.rectShape = null; 42 | } 43 | 44 | public getBBox(): RaphaelAxisAlignedBoundingBox { 45 | return this.rectShape?.getBBox()!; 46 | } 47 | } 48 | 49 | 50 | export default MultiSelectShape; 51 | -------------------------------------------------------------------------------- /apps/core/src/shape/root-node-shape.ts: -------------------------------------------------------------------------------- 1 | import NodeShape from './node-shape'; 2 | import type { NodeShapeOptions } from './node-shape'; 3 | 4 | export const fontSize = 25; 5 | const paddingWidth = 42; 6 | export const rectHeight = 52; 7 | 8 | export function createRootNodeShape(options: NodeShapeOptions): NodeShape { 9 | return new NodeShape({ 10 | ...options, 11 | labelBaseAttr: { 12 | 'font-size': fontSize, 13 | 'fill': '#fff', 14 | 'fill-opacity': 1, 15 | }, 16 | rectBaseAttr: { 17 | 'fill': '#3F89DE', 18 | 'fill-opacity': 1, 19 | 'stroke-opacity': 0, 20 | }, 21 | paddingWidth, 22 | rectHeight, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /apps/core/src/text-editor.ts: -------------------------------------------------------------------------------- 1 | import Node from './node/node'; 2 | import Viewport from './viewport'; 3 | import Selection from './selection/selection'; 4 | import { fontSize as rootFontSize } from './shape/root-node-shape'; 5 | import { fontSize as firstNodeFontSize } from './shape/first-node-shape'; 6 | import { fontSize as grandchildFontSize } from './shape/grandchild-node-shape'; 7 | import { DepthType } from './helper'; 8 | import DataHandler from './data/data-handler'; 9 | import PaperWrapper from './paper-wrapper'; 10 | import { isMobile } from './helper'; 11 | 12 | const fontSizeMap = { 13 | [DepthType.root]: rootFontSize, 14 | [DepthType.firstLevel]: firstNodeFontSize, 15 | [DepthType.grandchild]: grandchildFontSize, 16 | }; 17 | 18 | class TextEditor { 19 | private readonly editorWrapperDom: HTMLDivElement; 20 | private readonly editorDom: HTMLDivElement; 21 | private readonly viewport: Viewport; 22 | private readonly dataHandler: DataHandler; 23 | private node: Node | null = null; // 当前操作的节点,需要看见的时候才算操作 24 | private isShow: boolean = false; 25 | private isComposition: boolean = false; 26 | public constructor({ 27 | viewport, 28 | selection, 29 | dataHandler, 30 | paperWrapper, 31 | }: { 32 | viewport: Viewport; 33 | selection: Selection; 34 | dataHandler: DataHandler; 35 | paperWrapper: PaperWrapper; 36 | }) { 37 | this.viewport = viewport; 38 | this.dataHandler = dataHandler; 39 | 40 | const doms = this.initTextEditorElement(paperWrapper); 41 | this.editorWrapperDom = doms.editorWrapperDom; 42 | this.editorDom = doms.editorDom; 43 | 44 | if (isMobile) { 45 | return; 46 | } 47 | 48 | selection.on('select', () => { 49 | const selectNodes = selection.getSelectNodes(); 50 | 51 | // If selectNodes has only one and is different, then focus and set label 52 | if (selectNodes.length === 1 && this.node?.id !== selectNodes[0].id) { 53 | this.setLabel(); 54 | 55 | this.editorDom.focus(); 56 | this.hide(); 57 | 58 | this.node = selectNodes[0]; 59 | this.translate(); 60 | } else if (this.node !== null && (selectNodes.length !== 1 || selectNodes[0].id !== this.node.id)) { 61 | this.setLabel(); 62 | 63 | this.editorDom.blur(); 64 | this.hide(); 65 | 66 | this.node = null; 67 | } 68 | }); 69 | 70 | this.editorDom.addEventListener('compositionstart', () => { 71 | if (!this.isShow) this.show(); 72 | this.isComposition = true; 73 | }); 74 | 75 | this.editorDom.addEventListener('compositionend', () => { 76 | this.isComposition = false; 77 | }); 78 | 79 | 80 | this.editorDom.addEventListener('input', (event: Event) => { 81 | // @ts-ignore 82 | const inputValue = event.data; 83 | if (!this.isShow) { 84 | if (/\s/.test(inputValue)) { 85 | this.showBySelectionLabel(); 86 | } else if (this.isEditableKey(inputValue)) { 87 | this.show(); 88 | } else { 89 | this.editorDom.innerText = ''; 90 | } 91 | } 92 | }); 93 | } 94 | 95 | public showBySelectionLabel(): void { 96 | this.show(); 97 | this.editorDom.innerText = this.node?.label || ''; 98 | 99 | // @ts-ignore 100 | document.execCommand('selectAll', false, null); 101 | // @ts-ignore 102 | document.getSelection().collapseToEnd(); 103 | } 104 | 105 | public isShowing(): boolean { 106 | return this.isShow; 107 | } 108 | 109 | public finishEdit(): void { 110 | if (this.isComposition) return; 111 | this.setLabel(); 112 | this.hide(); 113 | } 114 | 115 | public hide(): void { 116 | this.editorWrapperDom.style.zIndex = '-9999'; 117 | this.editorDom.innerText = ''; 118 | this.isShow = false; 119 | } 120 | 121 | 122 | private show(): void { 123 | if (this.node === null) return; 124 | 125 | const scale = this.viewport.getScale(); 126 | const fontSize = fontSizeMap[this.node.getDepthType()] * scale; 127 | this.editorWrapperDom.style.zIndex = '3'; 128 | this.editorDom.style.fontSize = `${fontSize}px`; 129 | this.translate(); 130 | this.editorDom.focus(); 131 | 132 | this.isShow = true; 133 | } 134 | 135 | private initTextEditorElement(paperWrapper: PaperWrapper): { 136 | editorWrapperDom: HTMLDivElement; 137 | editorDom: HTMLDivElement; 138 | } { 139 | const editorWrapperDom = document.createElement('div'); 140 | editorWrapperDom.className = 'node-edit-text-wrapper'; 141 | 142 | const editorDom = document.createElement('div'); 143 | editorDom.className = 'node-edit-text'; 144 | editorDom.setAttribute('contenteditable', 'true'); 145 | 146 | const containerDom = paperWrapper.getContainerDom(); 147 | 148 | editorWrapperDom.appendChild(editorDom); 149 | containerDom.appendChild(editorWrapperDom); 150 | 151 | return { 152 | editorWrapperDom, 153 | editorDom, 154 | } 155 | } 156 | 157 | private isEditableKey(key: string): boolean { 158 | const editableOtherKeys = ['`', '-', '=', '[', ']', '\\', ';', '\'', ',', '.', '/']; 159 | return /^[\w]$/.test(key) || editableOtherKeys.includes(key); 160 | } 161 | 162 | private translate() { 163 | if (this.node === null) return; 164 | 165 | const { cx, cy } = this.node.getLabelBBox(); 166 | const { offsetX, offsetY } = this.viewport.getOffsetPosition(cx, cy); 167 | const scale = this.viewport.getScale(); 168 | 169 | const fontSize = fontSizeMap[this.node.getDepthType()]; 170 | 171 | const editorDomHeight = (1.4 * fontSize + 6) * scale; 172 | 173 | this.editorWrapperDom.style.left = `${offsetX - 300}px`; 174 | this.editorWrapperDom.style.top = `${offsetY - editorDomHeight / 2}px`; 175 | } 176 | 177 | private setLabel(): void { 178 | const newLabel = this.editorDom.innerText; 179 | if (this.node !== null && this.isShow && newLabel !== this.node.label) { 180 | this.dataHandler.update(this.node.id, { 181 | label: newLabel, 182 | }); 183 | } 184 | } 185 | } 186 | 187 | export default TextEditor; 188 | -------------------------------------------------------------------------------- /apps/core/src/tool-operation.ts: -------------------------------------------------------------------------------- 1 | import Node from './node/node'; 2 | import Tree from "./tree/tree"; 3 | import Selection from './selection/selection'; 4 | import DataHandler from './data/data-handler'; 5 | 6 | // public operation of Toolbar, Sub Toolbar and keyboard 7 | class ToolOperation { 8 | private readonly root: Node; 9 | private readonly tree: Tree; 10 | private readonly selection: Selection; 11 | private readonly dataHandler: DataHandler; 12 | public constructor({ 13 | root, 14 | tree, 15 | selection, 16 | dataHandler, 17 | }: { 18 | root: Node; 19 | tree: Tree; 20 | selection: Selection; 21 | dataHandler: DataHandler; 22 | }) { 23 | this.root = root; 24 | this.tree = tree; 25 | this.selection = selection; 26 | this.dataHandler = dataHandler; 27 | } 28 | 29 | public undo(): void { 30 | this.dataHandler.undo(); 31 | } 32 | 33 | public redo(): void { 34 | this.dataHandler.redo(); 35 | } 36 | 37 | public addChildNode(): void { 38 | const selectNode = this.selection.getSingleSelectNode(); 39 | if (!selectNode) return; 40 | this.dataHandler.addChildNode(selectNode.id, selectNode.depth + 1); 41 | } 42 | 43 | public addBrotherNode(): void { 44 | const selectNode = this.selection.getSingleSelectNode(); 45 | if (!selectNode) return; 46 | this.dataHandler.addBrotherNode(selectNode.id, selectNode.depth); 47 | } 48 | 49 | public removeNode(): void { 50 | const selectNodes = this.selection.getSelectNodes(); 51 | if (!selectNodes || selectNodes.length === 0) return; 52 | 53 | const selecNodeIds = selectNodes.map((selectNode) => selectNode.id); 54 | this.dataHandler.removeNode(selecNodeIds); 55 | } 56 | 57 | public getNodeMap(): Record { 58 | const nodeMap = {}; 59 | this.setNodeMapInner(this.root, nodeMap); 60 | return nodeMap; 61 | } 62 | 63 | // private selectByIds(sourceIds?: string[]) { 64 | // if (!sourceIds) return; 65 | 66 | // const nodeMap = this.getNodeMap(); 67 | 68 | // const selectNodes = sourceIds.reduce((nodes, sourceId) => { 69 | // if (nodeMap[sourceId]) nodes.push(nodeMap[sourceId]); 70 | // return nodes; 71 | // }, [] as Node[]); 72 | 73 | // this.selection.select(selectNodes); 74 | // } 75 | 76 | private setNodeMapInner(node: Node, nodeMap: Record): void { 77 | nodeMap[node.id] = node; 78 | node.children.forEach((child) => this.setNodeMapInner(child, nodeMap)); 79 | } 80 | } 81 | 82 | export default ToolOperation; 83 | -------------------------------------------------------------------------------- /apps/core/src/tree/tree-renderer.ts: -------------------------------------------------------------------------------- 1 | import Node from '../node/node'; 2 | import Position from '../position'; 3 | import type { NodeDataMap } from '../types'; 4 | import type { CreateNodeFunc } from '../node/node-creator'; 5 | 6 | interface RenderNewParams { 7 | nodeDataMap: NodeDataMap; 8 | sourceId: string; 9 | depth: number; 10 | father: Node | null; 11 | } 12 | 13 | interface RenderParams { 14 | nodeDataMap: NodeDataMap; 15 | sourceId: string; 16 | depth: number; 17 | relativeNode: Node; 18 | } 19 | 20 | class TreeRenderer { 21 | private readonly root: Node; 22 | private readonly position: Position; 23 | private readonly createNode: CreateNodeFunc; 24 | public constructor({ 25 | root, 26 | position, 27 | createNode, 28 | }: { 29 | root: Node; 30 | position: Position; 31 | createNode: CreateNodeFunc; 32 | }) { 33 | this.root = root; 34 | this.position = position; 35 | this.createNode = createNode; 36 | } 37 | 38 | public render(nodeDataMap?: NodeDataMap | null): void { 39 | if (!nodeDataMap) return; 40 | this.renderWithRelativeNode({ 41 | nodeDataMap, 42 | sourceId: this.root.id, 43 | relativeNode: this.root, 44 | depth: 0, 45 | }); 46 | this.position.reset(); 47 | } 48 | 49 | renderWithRelativeNode({ 50 | nodeDataMap, 51 | sourceId, 52 | relativeNode, 53 | depth, 54 | }: RenderParams): Node { 55 | const currentNode = relativeNode; 56 | const nodeData = nodeDataMap[sourceId]; 57 | 58 | if (nodeData.label !== currentNode.label) { 59 | currentNode.setLabel(nodeData.label); 60 | } 61 | 62 | if (nodeData.isExpand !== currentNode.isExpand) { 63 | currentNode.changeExpand(nodeData.isExpand!); 64 | } 65 | 66 | if (nodeData.direction !== currentNode.direction) { 67 | currentNode.changeDirection(nodeData.direction); 68 | } 69 | 70 | const oldChildren = currentNode.children; 71 | currentNode.clearChild(); 72 | 73 | nodeData.children.forEach((childId) => { 74 | const childNodeData = nodeDataMap[childId]; 75 | if (!childNodeData) return; 76 | 77 | const childRelativeNodeIndex = oldChildren.findIndex((node) => { 78 | return node.id === childId; 79 | }); 80 | 81 | let childRelativeNode: Node | null = null; 82 | 83 | if (childRelativeNodeIndex > -1) { 84 | const targetNodeList = oldChildren.splice(childRelativeNodeIndex, 1); 85 | childRelativeNode = targetNodeList[0]; 86 | } 87 | 88 | const childNode = childRelativeNode !== null 89 | ? this.renderWithRelativeNode({ 90 | nodeDataMap, 91 | sourceId: childId, 92 | relativeNode: childRelativeNode!, 93 | depth: depth + 1, 94 | }) 95 | : this.renderNew({ 96 | nodeDataMap, 97 | sourceId: childId, 98 | depth: depth + 1, 99 | father: currentNode, 100 | }); 101 | 102 | currentNode.pushChild(childNode); 103 | }); 104 | 105 | oldChildren.forEach((oldChild) => { 106 | oldChild.remove(); 107 | }); 108 | 109 | return currentNode; 110 | } 111 | 112 | renderNew({ 113 | nodeDataMap, 114 | sourceId, 115 | depth, 116 | father, 117 | }: RenderNewParams): Node { 118 | const nodeData = nodeDataMap[sourceId]; 119 | 120 | const currentNode = this.createNode({ 121 | id: sourceId, 122 | depth, 123 | label: nodeData.label, 124 | direction: nodeData.direction, 125 | father, 126 | isExpand: nodeData.isExpand, 127 | imageData: nodeData.imageData, 128 | link: nodeData.link, 129 | }); 130 | 131 | nodeData.children.forEach((childId) => { 132 | const childNodeData = nodeDataMap[childId]; 133 | if (!childNodeData) return; 134 | const childNode = this.renderNew({ 135 | nodeDataMap, 136 | sourceId: childId, 137 | depth: depth + 1, 138 | father: currentNode, 139 | }); 140 | currentNode.pushChild(childNode); 141 | }); 142 | 143 | return currentNode; 144 | } 145 | } 146 | 147 | export default TreeRenderer; 148 | -------------------------------------------------------------------------------- /apps/core/src/tree/tree.ts: -------------------------------------------------------------------------------- 1 | import Node from '../node/node'; 2 | import Position from '../position'; 3 | import Viewport from '../viewport'; 4 | import TreeRenderer from './tree-renderer'; 5 | import NodeCreator from '../node/node-creator'; 6 | import type { NodeDataMap } from '../types'; 7 | 8 | // Tree class, for rendering tree 9 | class Tree { 10 | private readonly root: Node; 11 | private readonly position: Position; 12 | private readonly treeRenderer: TreeRenderer; 13 | private readonly nodeCreator: NodeCreator; 14 | public constructor({ 15 | data, 16 | viewport, 17 | nodeCreator, 18 | }: { 19 | data: NodeDataMap; 20 | viewport: Viewport; 21 | nodeCreator: NodeCreator; 22 | }) { 23 | this.nodeCreator = nodeCreator; 24 | 25 | this.root = this.createRoot(data, viewport); 26 | this.position = new Position(this.root); 27 | 28 | this.treeRenderer = new TreeRenderer({ 29 | root: this.root, 30 | position: this.position, 31 | createNode: nodeCreator.createNode, 32 | }); 33 | 34 | this.render(data); 35 | } 36 | 37 | public getRoot(): Node { 38 | return this.root; 39 | } 40 | 41 | public clear(): void { 42 | this.root.remove(); 43 | } 44 | 45 | public render(nodeDataMap?: NodeDataMap | null): void { 46 | this.treeRenderer.render(nodeDataMap); 47 | } 48 | 49 | private createRoot( 50 | nodeDataMap: NodeDataMap = {}, 51 | viewport: Viewport, 52 | ): Node { 53 | let rootId = Object.keys(nodeDataMap).find((id) => { 54 | return nodeDataMap[id].isRoot === true; 55 | })!; 56 | 57 | const rootData = nodeDataMap[rootId]; 58 | 59 | const root = this.nodeCreator.createNode({ 60 | id: rootId, 61 | depth: 0, 62 | label: rootData.label, 63 | direction: rootData.direction, 64 | }); 65 | 66 | const { width: viewportWidth, height: viewportHeight } = viewport.getViewbox(); 67 | 68 | const rootBBox = root.getBBox(); 69 | const rootX = (viewportWidth - rootBBox.width) / 2; 70 | const rootY = (viewportHeight - rootBBox.height) / 2; 71 | root.translateTo(rootX, rootY); 72 | 73 | return root; 74 | } 75 | } 76 | 77 | export default Tree; -------------------------------------------------------------------------------- /apps/core/src/types.ts: -------------------------------------------------------------------------------- 1 | export enum Direction { 2 | LEFT = -1, 3 | NONE = 0, 4 | RIGHT = 1, 5 | } 6 | 7 | export interface ImageData { 8 | src: string; 9 | width: number; 10 | height: number; 11 | gap?: number; 12 | toward: 'left' | 'right'; 13 | } 14 | 15 | export interface NodeData { 16 | children: string[]; 17 | label: string; 18 | direction: Direction; 19 | isRoot?: boolean; 20 | isExpand?: boolean; 21 | imageData?: ImageData; 22 | link?: string; 23 | } 24 | 25 | export type NodeDataMap = Record; 26 | -------------------------------------------------------------------------------- /apps/core/src/viewport-interaction/drag-background.ts: -------------------------------------------------------------------------------- 1 | import DragViewportHandler from './drag-viewport-handler'; 2 | import { isMobile } from '../helper'; 3 | 4 | class DragBackground { 5 | public constructor( 6 | private readonly svgDom: SVGSVGElement | null, 7 | private readonly dragViewportHandler: DragViewportHandler, 8 | private able: boolean, 9 | ) { 10 | this.svgDom = svgDom; 11 | 12 | if (isMobile) { 13 | this.svgDom?.addEventListener('touchstart', this.handleMousedown); 14 | this.svgDom?.addEventListener('touchmove', this.handleMousemove); 15 | this.svgDom?.addEventListener('touchend', this.handleMouseup); 16 | 17 | } else { 18 | this.svgDom?.addEventListener('mousedown', this.handleMousedown); 19 | this.svgDom?.addEventListener('mousemove', this.handleMousemove); 20 | this.svgDom?.addEventListener('mouseup', this.handleMouseup); 21 | } 22 | } 23 | 24 | public disable(): void { 25 | this.able = false; 26 | } 27 | 28 | public enable(): void { 29 | this.able = true; 30 | } 31 | 32 | public clear(): void { 33 | this.svgDom?.removeEventListener('mousedown', this.handleMousedown); 34 | this.svgDom?.removeEventListener('mousemove', this.handleMousemove); 35 | this.svgDom?.removeEventListener('mouseup', this.handleMouseup); 36 | } 37 | 38 | private handleMousedown = (event: MouseEvent | TouchEvent): void => { 39 | if (!this.able) return; 40 | let clientX = 0; 41 | let clientY = 0; 42 | 43 | if (isMobile) { 44 | const mobileEvent = event as TouchEvent; 45 | clientX = mobileEvent.touches[0].clientX; 46 | clientY = mobileEvent.touches[0].clientY; 47 | event.preventDefault(); 48 | } else { 49 | const pcEvent = event as MouseEvent; 50 | clientX = pcEvent.clientX; 51 | clientY = pcEvent.clientY; 52 | } 53 | 54 | this.dragViewportHandler.handleMousedown(clientX, clientY); 55 | } 56 | 57 | private handleMousemove = (event: MouseEvent | TouchEvent): void => { 58 | let clientX = 0; 59 | let clientY = 0; 60 | 61 | if (isMobile) { 62 | const mobileEvent = event as TouchEvent; 63 | clientX = mobileEvent.touches[0].clientX; 64 | clientY = mobileEvent.touches[0].clientY; 65 | } else { 66 | const pcEvent = event as MouseEvent; 67 | clientX = pcEvent.clientX; 68 | clientY = pcEvent.clientY; 69 | } 70 | 71 | this.dragViewportHandler.handleMousemove(clientX, clientY); 72 | } 73 | 74 | private handleMouseup = (): void => { 75 | this.dragViewportHandler.handleMouseup(); 76 | } 77 | } 78 | 79 | export default DragBackground; 80 | 81 | -------------------------------------------------------------------------------- /apps/core/src/viewport-interaction/drag-root.ts: -------------------------------------------------------------------------------- 1 | import Node from '../node/node'; 2 | import DragViewportHandler from './drag-viewport-handler'; 3 | 4 | class DragRoot { 5 | private isStart: boolean = false; 6 | public constructor( 7 | private readonly root: Node, 8 | private readonly dragViewportHandler: DragViewportHandler 9 | ) { 10 | root.on('drag', this.move, this.start, this.end); 11 | this.dragViewportHandler = dragViewportHandler; 12 | } 13 | 14 | private start = (clientX: number, clientY: number, event: MouseEvent): void => { 15 | event.stopPropagation(); 16 | this.isStart = true; 17 | this.dragViewportHandler.handleMousedown.call(this.dragViewportHandler, clientX, clientY); 18 | } 19 | 20 | private move = (dx: number, dy: number, clientX: number, clientY: number): void => { 21 | if (!this.isStart) return; 22 | this.dragViewportHandler.handleMousemove.call(this.dragViewportHandler, clientX, clientY); 23 | } 24 | 25 | private end = (): void => { 26 | this.isStart = false; 27 | this.dragViewportHandler.handleMouseup.call(this.dragViewportHandler); 28 | } 29 | } 30 | 31 | export default DragRoot; 32 | -------------------------------------------------------------------------------- /apps/core/src/viewport-interaction/drag-viewport-handler.ts: -------------------------------------------------------------------------------- 1 | import Viewport from '../viewport'; 2 | 3 | const validDiff = 2; 4 | 5 | class DragViewportHandler { 6 | private isStart: boolean = false; 7 | private lastClientX: number = 0; 8 | private lastClientY: number = 0; 9 | private isMoveInited: boolean = false; 10 | 11 | public constructor(private readonly viewport: Viewport) { } 12 | 13 | public handleMousedown = (clientX: number, clientY: number): void => { 14 | this.isStart = true; 15 | this.lastClientX = clientX; 16 | this.lastClientY = clientY; 17 | } 18 | 19 | public handleMousemove = (clientX: number, clientY: number): void => { 20 | if (!this.isStart) return; 21 | 22 | const dx = this.lastClientX - clientX; 23 | const dy = this.lastClientY - clientY; 24 | 25 | if (!this.isMoveInited && (Math.abs(dx) > validDiff || Math.abs(dy) > validDiff)) { 26 | document.body.style.cursor = 'grabbing'; 27 | this.isMoveInited = true; 28 | } 29 | 30 | if (this.isMoveInited) { 31 | this.viewport.translate(dx, dy); 32 | } 33 | 34 | this.lastClientX = clientX; 35 | this.lastClientY = clientY; 36 | } 37 | 38 | public handleMouseup = (): void => { 39 | this.isStart = false; 40 | this.isMoveInited = false; 41 | this.lastClientX = 0; 42 | this.lastClientY = 0; 43 | 44 | document.body.style.cursor = 'default'; 45 | } 46 | } 47 | 48 | export default DragViewportHandler; 49 | -------------------------------------------------------------------------------- /apps/core/src/viewport-interaction/viewport-interaction.ts: -------------------------------------------------------------------------------- 1 | import Node from '../node/node'; 2 | import Viewport from '../viewport'; 3 | import PaperWrapper from '../paper-wrapper'; 4 | import DragViewportHandler from './drag-viewport-handler'; 5 | import DragRoot from './drag-root'; 6 | import DragBackground from './drag-background'; 7 | import ViewportWheel from './viewport-wheel'; 8 | import ViewportResize from './viewport-resize'; 9 | import { isMobile } from '../helper'; 10 | 11 | interface ViewportInteractionOptions { 12 | paperWrapper: PaperWrapper; 13 | viewport: Viewport; 14 | root: Node; 15 | } 16 | 17 | const zoomSpeed = 0.25; 18 | 19 | // viewport interfaction, for operation 20 | class ViewportInteraction { 21 | private readonly viewport: Viewport; 22 | private readonly root: Node; 23 | private readonly dragBackground: DragBackground; 24 | private readonly viewportWheel: ViewportWheel; 25 | private readonly viewportResize: ViewportResize; 26 | public constructor(options: ViewportInteractionOptions) { 27 | const { 28 | paperWrapper, 29 | viewport, 30 | root, 31 | } = options; 32 | 33 | this.viewport = viewport; 34 | this.root = root; 35 | 36 | const dragResult = this.initDrag(options); 37 | this.dragBackground = dragResult.dragBackground; 38 | 39 | const wrapperDom = paperWrapper.getWrapperDom(); 40 | this.viewportWheel = new ViewportWheel(viewport, wrapperDom); 41 | 42 | this.viewportResize = new ViewportResize(paperWrapper, viewport); 43 | } 44 | 45 | public disableBackgroundDrag = (): void => this.dragBackground.disable(); 46 | 47 | public enableBackgroundDrag = (): void => this.dragBackground.enable(); 48 | 49 | public enableMoveScale = (): void => this.viewportWheel.enableMoveScale(); 50 | 51 | public disableMoveScale = (): void => this.viewportWheel.disableMoveScale(); 52 | 53 | public zoomIn(): void { 54 | this.viewport.addScale(zoomSpeed); 55 | } 56 | 57 | public zoomOut(): void { 58 | this.viewport.addScale(-zoomSpeed); 59 | } 60 | 61 | public translateToCenter(): void { 62 | const rootBBox = this.root.getBBox(); 63 | const viewbox = this.viewport.getViewbox(); 64 | 65 | const targetX = rootBBox.cx - viewbox.width / 2; 66 | const targetY = rootBBox.cy - viewbox.height / 2; 67 | 68 | this.viewport.translateTo(targetX, targetY); 69 | } 70 | 71 | public clear(): void { 72 | this.dragBackground.clear(); 73 | this.viewportWheel.clear(); 74 | this.viewportResize.clear(); 75 | } 76 | 77 | private initDrag({ 78 | paperWrapper, 79 | viewport, 80 | root, 81 | }: ViewportInteractionOptions): { 82 | dragRoot: DragRoot; 83 | dragBackground: DragBackground; 84 | } { 85 | const dragViewportHandler = new DragViewportHandler(viewport); 86 | const svgDom = paperWrapper.getSvgDom(); 87 | 88 | const dragRoot = new DragRoot(root, dragViewportHandler); 89 | const dragBackground = new DragBackground(svgDom, dragViewportHandler, isMobile); 90 | 91 | return { 92 | dragRoot, 93 | dragBackground, 94 | }; 95 | } 96 | } 97 | 98 | export default ViewportInteraction; 99 | -------------------------------------------------------------------------------- /apps/core/src/viewport-interaction/viewport-resize.ts: -------------------------------------------------------------------------------- 1 | import PaperWrapper, { wrapperClassName } from "../paper-wrapper"; 2 | import Viewport from "../viewport"; 3 | 4 | class ViewportResize { 5 | private readonly resizeObserver: ResizeObserver; 6 | private readonly wrapperDom: HTMLDivElement; 7 | public constructor( paperWrapper: PaperWrapper, viewport: Viewport) { 8 | const wrapperDom = paperWrapper.getWrapperDom(); 9 | const svgDom = paperWrapper.getSvgDom(); 10 | this.wrapperDom = wrapperDom; 11 | 12 | this.resizeObserver = new ResizeObserver((entries) => { 13 | for (const entry of entries) { 14 | if (entry.target.className.includes(wrapperClassName)) { 15 | const { 16 | clientWidth, 17 | clientHeight, 18 | } = entry.target; 19 | 20 | svgDom?.setAttribute('width', `${clientWidth}`); 21 | svgDom?.setAttribute('height', `${clientHeight}`); 22 | viewport.resize(clientWidth, clientHeight) 23 | break; 24 | } 25 | } 26 | }); 27 | 28 | this.resizeObserver.observe(wrapperDom); 29 | } 30 | 31 | public clear(): void { 32 | this.resizeObserver.unobserve(this.wrapperDom); 33 | } 34 | } 35 | 36 | export default ViewportResize; 37 | -------------------------------------------------------------------------------- /apps/core/src/viewport-interaction/viewport-wheel.ts: -------------------------------------------------------------------------------- 1 | import Viewport from '../viewport'; 2 | 3 | class ViewportWheel { 4 | private isMoveZoom: boolean = false; 5 | public constructor( 6 | private readonly viewport: Viewport, 7 | private readonly wrapperDom: HTMLDivElement, 8 | ) { 9 | wrapperDom.addEventListener('wheel', this.wheelCallback); 10 | } 11 | 12 | public clear(): void { 13 | this.wrapperDom.removeEventListener('wheel', this.wheelCallback); 14 | } 15 | 16 | public enableMoveScale(): void { 17 | this.isMoveZoom = true; 18 | } 19 | 20 | public disableMoveScale(): void { 21 | this.isMoveZoom = false; 22 | } 23 | 24 | private wheelCallback = (event: WheelEvent): void => { 25 | event.preventDefault(); 26 | event.stopPropagation(); 27 | 28 | if (event.ctrlKey || this.isMoveZoom) { 29 | const speed = 0.02; 30 | const dScale = -Math.floor(event.deltaY) * speed; 31 | this.viewport.addScale(dScale); 32 | } else { 33 | const sensitivity = 0.6; 34 | const x = event.deltaX * sensitivity; 35 | const y = event.deltaY * sensitivity; 36 | this.viewport.translate(x, y); 37 | } 38 | }; 39 | } 40 | 41 | export default ViewportWheel; 42 | -------------------------------------------------------------------------------- /apps/core/src/viewport.ts: -------------------------------------------------------------------------------- 1 | import PaperWrapper from './paper-wrapper'; 2 | import EventEmitter from 'eventemitter3'; 3 | import type { RaphaelPaper } from 'raphael'; 4 | 5 | export interface Viewbox { 6 | x: number; 7 | y: number; 8 | width: number; 9 | height: number; 10 | } 11 | 12 | interface ViewportEventMap { 13 | changeScale: (scale: number) => void; 14 | } 15 | 16 | const maxScale = 3; 17 | const minScale = 0.25; 18 | 19 | class Viewport { 20 | private readonly paper: RaphaelPaper; 21 | private readonly eventEmitter: EventEmitter; 22 | private readonly viewbox: Viewbox = { x: 0, y: 0, width: 0, height: 0 }; 23 | private wrapperWidth: number; 24 | private wrapperHeight: number; 25 | private scale: number = 1; 26 | public constructor( 27 | private readonly paperWrapper: PaperWrapper, 28 | scale?: number, 29 | ) { 30 | if (scale && Number.isFinite(scale)) { 31 | this.scale = scale; 32 | } 33 | 34 | this.paper = this.paperWrapper.getPaper(); 35 | const paperSize = this.paperWrapper.getSize(); 36 | this.wrapperWidth = paperSize.width; 37 | this.wrapperHeight = paperSize.height; 38 | 39 | this.eventEmitter = new EventEmitter(); 40 | 41 | if (this.scale > maxScale) { 42 | this.scale = maxScale; 43 | } else if (this.scale < minScale) { 44 | this.scale = minScale; 45 | } 46 | this.setScale(this.scale); 47 | } 48 | 49 | public getViewbox(): Viewbox { 50 | return this.viewbox; 51 | } 52 | 53 | public getScale(): number { 54 | return this.scale; 55 | } 56 | 57 | public setScale(scale: number): void { 58 | if (scale > maxScale) { 59 | scale = maxScale; 60 | } else if (scale < minScale) { 61 | scale = minScale; 62 | } 63 | 64 | const viewbox = this.viewbox; 65 | 66 | viewbox.width = this.wrapperWidth / scale; 67 | viewbox.height = this.wrapperHeight / scale; 68 | viewbox.x = this.getScalePosition(this.scale, scale, viewbox.x, this.wrapperWidth); 69 | viewbox.y = this.getScalePosition(this.scale, scale, viewbox.y, this.wrapperHeight); 70 | this.scale = scale; 71 | 72 | this.setViewBox(); 73 | this.eventEmitter.emit('changeScale', this.scale); 74 | } 75 | 76 | public addScale(dScale: number): void { 77 | const newScale = this.scale + dScale; 78 | this.setScale(newScale); 79 | } 80 | 81 | public on>( 82 | eventName: T, 83 | callback: EventEmitter.EventListener 84 | ) { 85 | this.eventEmitter.on(eventName, callback); 86 | } 87 | 88 | public translate(dx: number, dy: number) { 89 | const viewbox = this.viewbox; 90 | viewbox.x = viewbox.x + (dx / this.scale); 91 | viewbox.y = viewbox.y + (dy / this.scale); 92 | this.setViewBox(); 93 | } 94 | 95 | public translateTo(x: number, y: number) { 96 | const viewbox = this.viewbox; 97 | viewbox.x = x; 98 | viewbox.y = y; 99 | this.setViewBox(); 100 | } 101 | 102 | public resize(width: number, height: number) { 103 | const viewbox = this.viewbox; 104 | viewbox.width = (width / this.wrapperWidth) * viewbox.width; 105 | viewbox.height = (height / this.wrapperHeight) * viewbox.height; 106 | 107 | this.setViewBox(); 108 | 109 | this.wrapperWidth = width; 110 | this.wrapperHeight = height; 111 | } 112 | 113 | public getViewportPosition(clientX: number, clientY: number): { 114 | x: number; 115 | y: number; 116 | } { 117 | const wrapperDom = this.paperWrapper.getWrapperDom(); 118 | const wrapperRect = wrapperDom.getBoundingClientRect(); 119 | 120 | return { 121 | x: this.viewbox.x + (clientX - wrapperRect.x) / this.scale, 122 | y: this.viewbox.y + (clientY - wrapperRect.y) / this.scale, 123 | }; 124 | } 125 | 126 | public getOffsetPosition(x: number, y: number): { 127 | offsetX: number; 128 | offsetY: number; 129 | } { 130 | return { 131 | offsetX: (x - this.viewbox.x) * this.scale, 132 | offsetY: (y - this.viewbox.y) * this.scale, 133 | } 134 | } 135 | 136 | private getScalePosition(oldScale: number, scale: number, oldPosition: number, wrapperSize: number) { 137 | return oldPosition + wrapperSize * ((1 / oldScale) - (1 / scale)) / 2; 138 | } 139 | 140 | private setViewBox(): void { 141 | const viewbox = this.viewbox; 142 | this.paper.setViewBox(viewbox.x, viewbox.y, viewbox.width, viewbox.height, true); 143 | } 144 | } 145 | 146 | export default Viewport; 147 | -------------------------------------------------------------------------------- /apps/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/vite.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/core/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { resolve } from 'path'; 3 | 4 | const coreDir = __dirname; 5 | 6 | export default defineConfig({ 7 | build: { 8 | lib: { 9 | entry: resolve(coreDir, 'src/index.ts'), 10 | name: 'MindmapTree', 11 | fileName: 'index', 12 | }, 13 | outDir: resolve(coreDir, 'dist'), 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /apps/page/collaborate-combine.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mindmap Tree 7 | 17 | 18 | 19 |
20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /apps/page/collaborate-combine.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockyRen/mindmaptree/b780e8fad8da7490ad4b7b81ced63de3076a8e95/apps/page/collaborate-combine.ts -------------------------------------------------------------------------------- /apps/page/collaborate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mindmap Tree 7 | 8 | 9 | 10 |
11 |
12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /apps/page/collaborate.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | import { initPage } from './common/init-page'; 3 | import { WebrtcProvider } from 'y-webrtc'; 4 | import './common/common.less'; 5 | 6 | const ydoc = new Y.Doc(); 7 | 8 | const mindmapTree = initPage({ 9 | pageName: 'collaborate', 10 | options: { 11 | ydoc, 12 | }, 13 | }); 14 | 15 | const provider = new WebrtcProvider('demo-room', ydoc); 16 | 17 | mindmapTree.bindAwareness(provider.awareness); 18 | 19 | -------------------------------------------------------------------------------- /apps/page/common/common.less: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | width: 100vw; 4 | height: 100vh; 5 | overflow: hidden; 6 | } 7 | 8 | #container { 9 | width: 100vw; 10 | height: 100vh; 11 | overflow: hidden; 12 | } 13 | 14 | .github-link { 15 | position: fixed; 16 | right: 30px; 17 | top: 30px; 18 | z-index: 3; 19 | 20 | .github-icon { 21 | display: block; 22 | width: 30px; 23 | height: 30px; 24 | background-image: url('../img/github.svg'); 25 | background-repeat: no-repeat; 26 | background-size: 100% 100%; 27 | cursor: pointer; 28 | } 29 | 30 | &.mobile { 31 | top: auto; 32 | right: 25px; 33 | bottom: 38px; 34 | } 35 | } 36 | 37 | 38 | .mobile-alert { 39 | position: fixed; 40 | z-index: 3; 41 | top: 12px; 42 | left: 50%; 43 | transform: translateX(-50%); 44 | width: 90%; 45 | background-color: #f4eaff; 46 | padding: 10px 8px; 47 | justify-content: center; 48 | display: flex; 49 | align-items: center; 50 | border-radius: 4px; 51 | transition: opacity 0.3s; 52 | opacity: 1; 53 | .mobile-alert-icon { 54 | width: 25px; 55 | height: 25px; 56 | margin-right: 8px; 57 | } 58 | .mobile-alert-text { 59 | color: #25252d; 60 | flex: 1; 61 | } 62 | .mobile-alert-close { 63 | width: 25px; 64 | height: 25px; 65 | margin-left: 8px; 66 | } 67 | } -------------------------------------------------------------------------------- /apps/page/common/helper.ts: -------------------------------------------------------------------------------- 1 | import { isMobile } from "../../core/src/helper"; 2 | 3 | export const getQuery = (key: string): string => { 4 | const urlSearchParams = new URLSearchParams(window.location.search); 5 | const params = Object.fromEntries(urlSearchParams.entries()); 6 | return params[key] || ''; 7 | }; 8 | 9 | export const createGithubLink = () => { 10 | const githubLink = document.createElement('div'); 11 | githubLink.classList.add('github-link'); 12 | const githubIcon = document.createElement('a'); 13 | githubIcon.classList.add('github-icon'); 14 | githubIcon.href = 'https://github.com/RockyRen/mindmaptree'; 15 | 16 | if (isMobile) { 17 | githubLink.classList.add('mobile'); 18 | } 19 | 20 | githubLink.appendChild(githubIcon); 21 | document.body.appendChild(githubLink); 22 | } 23 | -------------------------------------------------------------------------------- /apps/page/common/init-page.ts: -------------------------------------------------------------------------------- 1 | import MindemapTree from '../../core/src/index'; 2 | import Store from './store'; 3 | import { getQuery, createGithubLink } from './helper'; 4 | import type { MindmapTreeOptions } from '../../core/src/index'; 5 | 6 | export const initPage = ({ 7 | pageName, 8 | options, 9 | }: { 10 | pageName?: string; 11 | options?: Partial; 12 | }): MindemapTree => { 13 | createGithubLink(); 14 | 15 | const store = new Store(pageName); 16 | 17 | const data = store.getData() || options?.data; 18 | 19 | const mindmapTree = new MindemapTree({ 20 | container: '#container', 21 | isDebug: getQuery('debug') === '1', 22 | scale: parseFloat(getQuery('scale')), 23 | ...options, 24 | data, 25 | }); 26 | 27 | mindmapTree.on('data', ({ data }) => { 28 | store.save(data); 29 | }); 30 | 31 | return mindmapTree; 32 | }; 33 | -------------------------------------------------------------------------------- /apps/page/common/mobile-alert.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import infoSvg from '../img/info.svg'; 3 | // @ts-ignore 4 | import closeSvg from '../img/close.svg'; 5 | 6 | let timer: ReturnType | null = null; 7 | 8 | export const showMobileAlert = (text: string, duration: number = 10000): void => { 9 | const mobileAlert = document.createElement('div'); 10 | mobileAlert.classList.add('mobile-alert'); 11 | const mobileAlertIcon = document.createElement('img'); 12 | mobileAlertIcon.classList.add('mobile-alert-icon'); 13 | mobileAlertIcon.src = infoSvg; 14 | const mobileAlertText = document.createElement('div'); 15 | mobileAlertText.classList.add('mobile-alert-text'); 16 | const mobileAlertClose = document.createElement('img'); 17 | mobileAlertClose.classList.add('mobile-alert-close'); 18 | mobileAlertClose.src = closeSvg; 19 | 20 | mobileAlertText.innerText = text; 21 | 22 | mobileAlert.appendChild(mobileAlertIcon); 23 | mobileAlert.appendChild(mobileAlertText); 24 | mobileAlert.appendChild(mobileAlertClose); 25 | document.body.appendChild(mobileAlert); 26 | 27 | 28 | const close = () => { 29 | mobileAlert.style.opacity = '0'; 30 | setTimeout(() => { 31 | mobileAlert.style.zIndex = '-1'; 32 | }, 310); 33 | } 34 | 35 | mobileAlertClose.addEventListener('click', () => { 36 | close(); 37 | timer && clearTimeout(timer); 38 | }, false); 39 | 40 | timer = setTimeout(() => { 41 | close(); 42 | }, duration); 43 | }; 44 | -------------------------------------------------------------------------------- /apps/page/common/store.ts: -------------------------------------------------------------------------------- 1 | import { getQuery } from './helper'; 2 | import type { NodeDataMap } from '../../core/src/types'; 3 | 4 | const pageId = getQuery('id'); 5 | 6 | class Store { 7 | private readonly data: NodeDataMap | null = null; 8 | private pageKey: string = 'page'; 9 | public constructor(pageKey?: string) { 10 | if (pageKey) { 11 | this.pageKey = pageKey; 12 | } 13 | if (pageId) { 14 | const strData = localStorage.getItem(`${this.pageKey}-${pageId}`) || ''; 15 | if (strData) { 16 | this.data = JSON.parse(strData); 17 | } 18 | } 19 | } 20 | 21 | public getData(): NodeDataMap | null { 22 | return this.data; 23 | } 24 | 25 | public save(data: NodeDataMap): void { 26 | const strData = JSON.stringify(data); 27 | localStorage.setItem(`${this.pageKey}-${pageId}`, strData); 28 | console.log('save', strData); 29 | } 30 | } 31 | 32 | export default Store; 33 | -------------------------------------------------------------------------------- /apps/page/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mindmap Tree Demo 7 | 8 | 9 | 10 |
11 |
12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /apps/page/demo.ts: -------------------------------------------------------------------------------- 1 | import { initPage } from './common/init-page'; 2 | import type { NodeDataMap } from '../core/src/types'; 3 | import './common/common.less'; 4 | 5 | const data: NodeDataMap = { 6 | '1': { 7 | label: 'My Holiday', 8 | direction: 0, 9 | isRoot: true, 10 | children: ['2', '5', '7'], 11 | }, 12 | '2': { 13 | label: 'Morning', 14 | direction: 1, 15 | children: ['3', '4'], 16 | }, 17 | '3': { 18 | label: 'Read book', 19 | direction: 1, 20 | children: [], 21 | }, 22 | '4': { 23 | label: 'Cook', 24 | direction: 1, 25 | children: [], 26 | }, 27 | '5': { 28 | label: 'Afternoon', 29 | direction: 1, 30 | children: ['6'], 31 | }, 32 | '6': { 33 | label: 'Baseball competition', 34 | direction: 1, 35 | children: [], 36 | }, 37 | '7': { 38 | label: 'Evening', 39 | direction: -1, 40 | children: ['8'], 41 | isExpand: false, 42 | }, 43 | '8': { 44 | label: 'Happy Dinner', 45 | direction: -1, 46 | children: [], 47 | }, 48 | }; 49 | 50 | initPage({ 51 | pageName: 'demo', 52 | options: { 53 | data, 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /apps/page/img/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/page/img/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/page/img/info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/page/img/resume/advanced.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockyRen/mindmaptree/b780e8fad8da7490ad4b7b81ced63de3076a8e95/apps/page/img/resume/advanced.png -------------------------------------------------------------------------------- /apps/page/img/resume/baseball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockyRen/mindmaptree/b780e8fad8da7490ad4b7b81ced63de3076a8e95/apps/page/img/resume/baseball.png -------------------------------------------------------------------------------- /apps/page/img/resume/front.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockyRen/mindmaptree/b780e8fad8da7490ad4b7b81ced63de3076a8e95/apps/page/img/resume/front.gif -------------------------------------------------------------------------------- /apps/page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mindmap Tree 7 | 8 | 9 | 10 |
11 |
12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /apps/page/index.ts: -------------------------------------------------------------------------------- 1 | import { initPage } from './common/init-page'; 2 | import './common/common.less'; 3 | 4 | initPage({}); 5 | -------------------------------------------------------------------------------- /apps/page/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "page", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "tsconfig": "workspace:*" 14 | }, 15 | "dependencies": { 16 | "y-webrtc": "^10.2.5", 17 | "yjs": "^13.6.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/page/resume.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | Mindmap Tree Demo 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /apps/page/resume.ts: -------------------------------------------------------------------------------- 1 | import { initPage } from './common/init-page'; 2 | import { showMobileAlert } from './common/mobile-alert'; 3 | import { data } from './data/resume-data'; 4 | import './common/common.less'; 5 | import { isMobile } from '../core/src/helper'; 6 | 7 | if (isMobile) { 8 | showMobileAlert('移动端不支持多数功能,请到PC端体验'); 9 | } 10 | 11 | initPage({ 12 | pageName: 'resume', 13 | options: { 14 | data, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /apps/page/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/vite.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/page/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { resolve } from 'path'; 4 | import { readdirSync } from 'fs'; 5 | 6 | const appDir = __dirname; 7 | const rootDir = resolve(appDir, '../../'); 8 | 9 | const getHtmlInputMap = (): Record => { 10 | const files = readdirSync(appDir); 11 | const htmlPattern = /([\w_-]+)\.html/; 12 | return files.reduce((htmlInputMap, file) => { 13 | const result = file.match(htmlPattern); 14 | const htmlName = result?.[1]; 15 | 16 | if (htmlName) { 17 | htmlInputMap[htmlName] = resolve(appDir, file); 18 | } 19 | return htmlInputMap; 20 | }, {} as Record); 21 | }; 22 | 23 | export default defineConfig({ 24 | plugins: [react()], 25 | root: appDir, 26 | base: '/mindmaptree', 27 | build: { 28 | rollupOptions: { 29 | input: getHtmlInputMap(), 30 | }, 31 | outDir: resolve(rootDir, 'docs'), 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /build/core-vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { resolve } from 'path'; 3 | 4 | const rootDir = process.cwd(); 5 | const coreDir = resolve(rootDir, 'core'); 6 | 7 | export default defineConfig({ 8 | build: { 9 | lib: { 10 | entry: resolve(coreDir, 'src/index.ts'), 11 | name: 'MindmapTree', 12 | fileName: 'index', 13 | }, 14 | outDir: resolve(coreDir, 'dist'), 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /build/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | npm run build:core 6 | 7 | git checkout gh-pages 8 | git merge master 9 | 10 | rm -R docs/ 11 | npm run build 12 | 13 | git add . 14 | git commit -m "publish" 15 | git push origin gh-pages 16 | 17 | git checkout master -------------------------------------------------------------------------------- /build/page-vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { resolve } from 'path'; 4 | import { readdirSync } from 'fs'; 5 | 6 | const rootDir = process.cwd(); 7 | const pageDir = resolve(rootDir, 'page'); 8 | 9 | const getHtmlInputMap = (): Record => { 10 | const files = readdirSync(pageDir); 11 | const htmlPattern = /([\w_-]+)\.html/; 12 | return files.reduce((htmlInputMap, file) => { 13 | const result = file.match(htmlPattern); 14 | const htmlName = result?.[1]; 15 | 16 | if (htmlName) { 17 | htmlInputMap[htmlName] = resolve(pageDir, file); 18 | } 19 | return htmlInputMap; 20 | }, {} as Record); 21 | }; 22 | 23 | export default defineConfig({ 24 | plugins: [react()], 25 | root: pageDir, 26 | base: '/mindmaptree', 27 | build: { 28 | rollupOptions: { 29 | input: getHtmlInputMap(), 30 | }, 31 | outDir: resolve(rootDir, 'docs'), 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mindmap-tree-project", 3 | "version": "0.0.1", 4 | "description": "", 5 | "scripts": { 6 | "dev": "turbo run dev --filter=page", 7 | "copy:page": "copy ./wiki/*.jpg ./docs/assets/ && copy ./wiki/*.pdf ./docs/assets/ && copy ./wiki/*.png ./docs/assets/", 8 | "build": "turbo run build --force && npm run copy:page", 9 | "dev:core": "turbo run dev --filter=mindmap-tree", 10 | "build:core": "turbo run build --filter=mindmap-tree", 11 | "dev:server": "turbo run dev --filter=server", 12 | "build:server": "turbo run build --filter=server" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/RockyRen/mindmaptree.git" 17 | }, 18 | "author": "RockyRen", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/RockyRen/mindmaptree/issues" 22 | }, 23 | "homepage": "https://github.com/RockyRen/mindmaptree#readme", 24 | "dependencies": { 25 | "eventemitter3": "^5.0.0", 26 | "raphael": "^2.3.0", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "uuid": "^8.3.2" 30 | }, 31 | "devDependencies": { 32 | "@types/raphael": "^2.3.3", 33 | "@types/react": "^18.0.15", 34 | "@types/react-dom": "^18.0.6", 35 | "@vitejs/plugin-react": "^3.1.0", 36 | "copy": "^0.3.2", 37 | "less": "^4.1.3", 38 | "typescript": "^4.9.5", 39 | "vite": "^4.1.1", 40 | "turbo": "latest" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/tsconfig/vite.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "composite": true 19 | }, 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "start": {}, 5 | "dev": { 6 | "cache": false, 7 | "persistent": false 8 | }, 9 | "build": {} 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /wiki/README.zh.md: -------------------------------------------------------------------------------- 1 |

2 | Mindmap-Tree 3 |

4 | 5 |

6 | 一个基于web(svg)的思维导图 7 |

8 | 9 |

10 | 11 | npm 12 | 13 | 14 | ci 15 | 16 |

17 | 18 | [![mindmap-tree demo](https://rockyren.github.io/mindmaptree/assets/wiki/demo.jpg)](https://rockyren.github.io/mindmaptree/demo.html) 19 | 20 | ## Demo 21 | [Demo](https://rockyren.github.io/mindmaptree/resume.html) 22 | 23 | ## 相关文章 24 | * [Web思维导图实现与前端架构思考](https://juejin.cn/post/7202495679405654075) 25 | 26 | ## 功能 27 | * 添加 & 删除节点 28 | * 编辑节点文本 29 | * 撤销 & 重做 30 | * 修改视图scale 31 | * 拖拽改变节点关系 32 | * 键盘操作 33 | * 多选操作 34 | * 展开 & 收缩节点 35 | 36 | 37 | ## 开始使用 38 | 39 | ### 安装 40 | 41 | ```sh 42 | npm install -S mindmap-tree 43 | ``` 44 | 45 | ### 使用 46 | 47 | ```html 48 | 49 |
50 | 51 | ``` 52 | 53 | ```js 54 | import MindmapTree from 'mindmap-tree'; 55 | import 'mindmap-tree/style.css'; 56 | 57 | new MindmapTree({ 58 | container: '#container', 59 | }); 60 | ``` 61 | 62 | ### 参数 63 | 64 | MindmapTree constructor 参数: 65 | 66 | | Prop | Type | Default | Description | 67 | |-----------------|:-------:|---------|--------------------------------------------------------| 68 | | **container** | String \| Element | '' | container的HTML元素 | 69 | | **data** | NodeDataMap | Record | 思维导图的初始化数据 | 70 | | **isDebug** | Boolean | false | 是否调试 | 71 | 72 | NodeData params: 73 | 74 | | Prop | Type | Default | Description | 75 | |-----------------|:-------:|---------|--------------------------------------------------------| 76 | | **label** | String | '' | 节点文本 | 77 | | **direction** | Number | 0 | 节点方向, 1:右边, 0:无, -1:左边 | 78 | | **isRoot** | Boolean | false | 是否根节点 | 79 | | **children** | String[] | [] | 子节点的id数组 | 80 | | **isExpand** | Boolean | true | 是否展开节点 | 81 | 82 | ## License 83 | 84 | [MIT](https://github.com/RockyRen/mindmaptree/blob/master/LICENSE) 85 | 86 | Copyright (c) 2023 - present, RockyRen 87 | -------------------------------------------------------------------------------- /wiki/demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockyRen/mindmaptree/b780e8fad8da7490ad4b7b81ced63de3076a8e95/wiki/demo.jpg -------------------------------------------------------------------------------- /wiki/resume.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockyRen/mindmaptree/b780e8fad8da7490ad4b7b81ced63de3076a8e95/wiki/resume.pdf -------------------------------------------------------------------------------- /wiki/resume.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockyRen/mindmaptree/b780e8fad8da7490ad4b7b81ced63de3076a8e95/wiki/resume.png --------------------------------------------------------------------------------