├── .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 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 简体中文文档
21 |
22 |
23 | [](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 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 简体中文文档
21 |
22 |
23 | [](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 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | [](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
--------------------------------------------------------------------------------