= {
13 | side,
14 | left,
15 | right,
16 | full,
17 | living,
18 | zoomin,
19 | zoomout,
20 | }
21 | const createButton = (id: string, name: string) => {
22 | const button = document.createElement('span')
23 | button.id = id
24 | button.innerHTML = map[name]
25 | return button
26 | }
27 |
28 | function createToolBarRBContainer(mind: MindElixirInstance) {
29 | const toolBarRBContainer = document.createElement('div')
30 | const fc = createButton('fullscreen', 'full')
31 | const gc = createButton('toCenter', 'living')
32 | const zo = createButton('zoomout', 'zoomout')
33 | const zi = createButton('zoomin', 'zoomin')
34 | const percentage = document.createElement('span')
35 | percentage.innerText = '100%'
36 | toolBarRBContainer.appendChild(fc)
37 | toolBarRBContainer.appendChild(gc)
38 | toolBarRBContainer.appendChild(zo)
39 | toolBarRBContainer.appendChild(zi)
40 | // toolBarRBContainer.appendChild(percentage)
41 | toolBarRBContainer.className = 'mind-elixir-toolbar rb'
42 | fc.onclick = () => {
43 | if (document.fullscreenElement === mind.el) {
44 | document.exitFullscreen()
45 | } else {
46 | mind.el.requestFullscreen()
47 | }
48 | }
49 | gc.onclick = () => {
50 | mind.toCenter()
51 | }
52 | zo.onclick = () => {
53 | mind.scale(mind.scaleVal - mind.scaleSensitivity)
54 | }
55 | zi.onclick = () => {
56 | mind.scale(mind.scaleVal + mind.scaleSensitivity)
57 | }
58 | return toolBarRBContainer
59 | }
60 | function createToolBarLTContainer(mind: MindElixirInstance) {
61 | const toolBarLTContainer = document.createElement('div')
62 | const l = createButton('tbltl', 'left')
63 | const r = createButton('tbltr', 'right')
64 | const s = createButton('tblts', 'side')
65 |
66 | toolBarLTContainer.appendChild(l)
67 | toolBarLTContainer.appendChild(r)
68 | toolBarLTContainer.appendChild(s)
69 | toolBarLTContainer.className = 'mind-elixir-toolbar lt'
70 | l.onclick = () => {
71 | mind.initLeft()
72 | }
73 | r.onclick = () => {
74 | mind.initRight()
75 | }
76 | s.onclick = () => {
77 | mind.initSide()
78 | }
79 | return toolBarLTContainer
80 | }
81 |
82 | export default function (mind: MindElixirInstance) {
83 | mind.container.append(createToolBarRBContainer(mind))
84 | mind.container.append(createToolBarLTContainer(mind))
85 | }
86 |
--------------------------------------------------------------------------------
/src/plugin/selection.ts:
--------------------------------------------------------------------------------
1 | import type { MindElixirInstance, Topic } from '..'
2 | import type { Behaviour } from '../viselect/src'
3 | import SelectionArea from '../viselect/src'
4 |
5 | export default function (mei: MindElixirInstance) {
6 | const triggers: Behaviour['triggers'] = mei.mouseSelectionButton === 2 ? [2] : [0]
7 | const selection = new SelectionArea({
8 | selectables: ['.map-container me-tpc'],
9 | boundaries: [mei.container],
10 | container: mei.selectionContainer,
11 | mindElixirInstance: mei, // 传递 MindElixir 实例
12 | features: {
13 | // deselectOnBlur: true,
14 | touch: false,
15 | },
16 | behaviour: {
17 | triggers,
18 | // Scroll configuration.
19 | scrolling: {
20 | // On scrollable areas the number on px per frame is devided by this amount.
21 | // Default is 10 to provide a enjoyable scroll experience.
22 | speedDivider: 10,
23 | startScrollMargins: { x: 50, y: 50 },
24 | },
25 | },
26 | })
27 | .on('beforestart', ({ event }) => {
28 | if (mei.spacePressed) return false
29 | const target = event!.target as HTMLElement
30 | if (target.id === 'input-box') return false
31 | if (target.className === 'circle') return false
32 | if (mei.container.querySelector('.context-menu')?.contains(target)) {
33 | // prevent context menu click clear selection
34 | return false
35 | }
36 | if (!(event as MouseEvent).ctrlKey && !(event as MouseEvent).metaKey) {
37 | if (target.tagName === 'ME-TPC' && target.classList.contains('selected')) {
38 | // Normal click cannot deselect
39 | // Also, deselection CANNOT be triggered before dragging, otherwise we can't drag multiple targets!!
40 | return false
41 | }
42 | // trigger `move` event here
43 | mei.clearSelection()
44 | }
45 | // console.log('beforestart')
46 | const selectionAreaElement = selection.getSelectionArea()
47 | selectionAreaElement.style.background = '#4f90f22d'
48 | selectionAreaElement.style.border = '1px solid #4f90f2'
49 | if (selectionAreaElement.parentElement) {
50 | selectionAreaElement.parentElement.style.zIndex = '9999'
51 | }
52 | return true
53 | })
54 | // .on('beforedrag', ({ event }) => {})
55 | .on(
56 | 'move',
57 | ({
58 | store: {
59 | changed: { added, removed },
60 | },
61 | }) => {
62 | if (added.length > 0 || removed.length > 0) {
63 | // console.log('added ', added)
64 | // console.log('removed ', removed)
65 | }
66 | if (added.length > 0) {
67 | for (const el of added) {
68 | el.className = 'selected'
69 | }
70 | mei.currentNodes = [...mei.currentNodes, ...(added as Topic[])]
71 | mei.bus.fire(
72 | 'selectNodes',
73 | (added as Topic[]).map(el => el.nodeObj)
74 | )
75 | }
76 | if (removed.length > 0) {
77 | for (const el of removed) {
78 | el.classList.remove('selected')
79 | }
80 | mei.currentNodes = mei.currentNodes!.filter(el => !removed?.includes(el))
81 | mei.bus.fire(
82 | 'unselectNodes',
83 | (removed as Topic[]).map(el => el.nodeObj)
84 | )
85 | }
86 | }
87 | )
88 | mei.selection = selection
89 | }
90 |
--------------------------------------------------------------------------------
/src/exampleData/htmlText.ts:
--------------------------------------------------------------------------------
1 | export const katexHTML = `[x​y​][ab​cd​]
`
2 |
3 | export const codeBlock = `let message = 'Hello world'
4 | alert(message)
`
5 |
6 | export const styledDiv = ``
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mind-elixir",
3 | "version": "5.3.8",
4 | "type": "module",
5 | "description": "Mind elixir is a free open source mind map core.",
6 | "keywords": [
7 | "mind-elixir",
8 | "mindmap",
9 | "dom",
10 | "visualization"
11 | ],
12 | "scripts": {
13 | "prepare": "husky install",
14 | "lint": "eslint --cache --max-warnings 0 \"src/**/*.{js,json,ts}\" --fix",
15 | "dev": "vite",
16 | "build": "node build.js && tsc",
17 | "tsc": "tsc",
18 | "preview": "vite preview",
19 | "test": "playwright test",
20 | "test:ui": "playwright test --ui",
21 | "test:clean": "rimraf .nyc_output coverage",
22 | "test:coverage": "pnpm test:clean && pnpm test && pnpm nyc && npx http-server ./coverage",
23 | "nyc": "nyc report --reporter=html",
24 | "doc": "api-extractor run --local --verbose",
25 | "doc:md": "api-documenter markdown --input-folder ./api --output-folder ./md",
26 | "beta": "npm run build && npm publish --tag beta"
27 | },
28 | "exports": {
29 | ".": {
30 | "types": "./dist/types/index.d.ts",
31 | "import": "./dist/MindElixir.js",
32 | "require": "./dist/MindElixir.js"
33 | },
34 | "./lite": {
35 | "import": "./dist/MindElixirLite.iife.js"
36 | },
37 | "./example": {
38 | "types": "./dist/types/exampleData/1.d.ts",
39 | "import": "./dist/example.js",
40 | "require": "./dist/example.js"
41 | },
42 | "./LayoutSsr": {
43 | "types": "./dist/types/utils/LayoutSsr.d.ts",
44 | "import": "./dist/LayoutSsr.js",
45 | "require": "./dist/LayoutSsr.js"
46 | },
47 | "./readme.md": "./readme.md",
48 | "./package.json": "./package.json",
49 | "./style": "./dist/MindElixir.css",
50 | "./style.css": "./dist/MindElixir.css"
51 | },
52 | "typesVersions": {
53 | "*": {
54 | "example": [
55 | "./dist/types/exampleData/1.d.ts"
56 | ]
57 | }
58 | },
59 | "main": "dist/MindElixir.js",
60 | "types": "dist/types/index.d.ts",
61 | "lint-staged": {
62 | "src/**/*.{ts,js}": [
63 | "eslint --cache --fix"
64 | ],
65 | "src/**/*.{json,less}": [
66 | "prettier --write"
67 | ]
68 | },
69 | "files": [
70 | "package.json",
71 | "dist"
72 | ],
73 | "repository": "github:SSShooter/mind-elixir-core",
74 | "homepage": "https://mind-elixir.com/",
75 | "author": "ssshooter",
76 | "license": "MIT",
77 | "devDependencies": {
78 | "@commitlint/cli": "^20.0.0",
79 | "@commitlint/config-conventional": "^20.0.0",
80 | "@eslint/eslintrc": "^3.3.1",
81 | "@eslint/js": "^9.36.0",
82 | "@microsoft/api-documenter": "^7.26.34",
83 | "@microsoft/api-extractor": "^7.52.13",
84 | "@playwright/test": "^1.55.1",
85 | "@rollup/plugin-strip": "^3.0.4",
86 | "@types/node": "^24.5.2",
87 | "@typescript-eslint/eslint-plugin": "^8.44.1",
88 | "@typescript-eslint/parser": "^8.44.1",
89 | "@viselect/vanilla": "3.9.0",
90 | "@zumer/snapdom": "^1.9.11",
91 | "eslint": "^9.36.0",
92 | "eslint-config-prettier": "^10.1.8",
93 | "eslint-plugin-prettier": "^5.5.4",
94 | "globals": "^16.4.0",
95 | "husky": "^9.1.7",
96 | "katex": "^0.16.22",
97 | "less": "^4.4.1",
98 | "lint-staged": "^16.2.1",
99 | "marked": "^16.3.0",
100 | "nyc": "^17.1.0",
101 | "prettier": "3.6.2",
102 | "rimraf": "^6.0.1",
103 | "simple-markdown-to-html": "^1.0.0",
104 | "typescript": "^5.9.2",
105 | "vite": "^7.1.7",
106 | "vite-plugin-istanbul": "^7.2.0"
107 | }
108 | }
--------------------------------------------------------------------------------
/tests/interaction.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from './mind-elixir-test'
2 |
3 | const id = 'root-id'
4 | const topic = 'root-topic'
5 | const childTopic = 'child-topic'
6 | const data = {
7 | nodeData: {
8 | topic,
9 | id,
10 | children: [
11 | {
12 | id: 'middle',
13 | topic: 'middle',
14 | children: [
15 | {
16 | id: 'child',
17 | topic: childTopic,
18 | },
19 | ],
20 | },
21 | ],
22 | },
23 | }
24 |
25 | test.beforeEach(async ({ me }) => {
26 | await me.init(data)
27 | })
28 |
29 | test('Edit Node', async ({ page, me }) => {
30 | await me.dblclick(topic)
31 | await expect(page.locator('#input-box')).toBeVisible()
32 | await page.keyboard.insertText('update node')
33 | await page.keyboard.press('Enter')
34 | await expect(page.locator('#input-box')).toBeHidden()
35 | await expect(page.getByText('update node')).toBeVisible()
36 | await me.toHaveScreenshot()
37 | })
38 |
39 | test('Clear and reset', async ({ page, me }) => {
40 | await me.dblclick(topic)
41 | await expect(page.locator('#input-box')).toBeVisible()
42 | await page.keyboard.press('Backspace')
43 | await page.keyboard.press('Enter')
44 | await expect(page.locator('#input-box')).toBeHidden()
45 | await expect(page.getByText(topic)).toBeVisible()
46 | await me.toHaveScreenshot()
47 | })
48 |
49 | test('Remove Node', async ({ page, me }) => {
50 | await me.click(childTopic)
51 | await page.keyboard.press('Delete')
52 | await expect(page.getByText(childTopic)).toBeHidden()
53 | await me.toHaveScreenshot()
54 | })
55 |
56 | test('Add Sibling', async ({ page, me }) => {
57 | await me.click(childTopic)
58 | await page.keyboard.press('Enter')
59 | await page.keyboard.press('Enter')
60 | await expect(page.locator('#input-box')).toBeHidden()
61 | await expect(page.getByText('New Node')).toBeVisible()
62 | await me.toHaveScreenshot()
63 | })
64 |
65 | test('Add Before', async ({ page, me }) => {
66 | await me.click(childTopic)
67 | await page.keyboard.press('Shift+Enter')
68 | await page.keyboard.press('Enter')
69 | await expect(page.locator('#input-box')).toBeHidden()
70 | await expect(page.getByText('New Node')).toBeVisible()
71 | await me.toHaveScreenshot()
72 | })
73 |
74 | test('Add Parent', async ({ page, me }) => {
75 | await me.click(childTopic)
76 | await page.keyboard.press('Control+Enter')
77 | await page.keyboard.insertText('new node')
78 | await page.keyboard.press('Enter')
79 | await expect(page.locator('#input-box')).toBeHidden()
80 | await expect(page.getByText('new node')).toBeVisible()
81 | await me.toHaveScreenshot()
82 | })
83 |
84 | test('Add Child', async ({ page, me }) => {
85 | await me.click(childTopic)
86 | await page.keyboard.press('Tab')
87 | await page.keyboard.insertText('new node')
88 | await page.keyboard.press('Enter')
89 | await expect(page.locator('#input-box')).toBeHidden()
90 | await expect(page.getByText('new node')).toBeVisible()
91 | await me.toHaveScreenshot()
92 | })
93 |
94 | test('Copy and Paste', async ({ page, me }) => {
95 | await me.click('middle')
96 | await page.keyboard.press('Control+c')
97 | await me.click('child-topic')
98 | await page.keyboard.press('Control+v')
99 | // I guess Playwright will auto-scroll before taking screenshots
100 | // After changing the scrolling solution to transform, we can't get complete me-nodes screenshot through scrolling
101 | // This is indeed a very quirky "feature"
102 | await me.toHaveScreenshot(page.locator('.map-container'))
103 | })
104 |
--------------------------------------------------------------------------------
/index.css:
--------------------------------------------------------------------------------
1 | code[class*='language-'],
2 | pre[class*='language-'] {
3 | color: black;
4 | background: none;
5 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
6 | text-align: left;
7 | white-space: pre;
8 | word-spacing: normal;
9 | word-break: normal;
10 | word-wrap: normal;
11 | line-height: 1.5;
12 |
13 | -moz-tab-size: 4;
14 | -o-tab-size: 4;
15 | tab-size: 4;
16 |
17 | -webkit-hyphens: none;
18 | -moz-hyphens: none;
19 | -ms-hyphens: none;
20 | hyphens: none;
21 | }
22 |
23 | /* Code blocks */
24 | pre[class*='language-'] {
25 | position: relative;
26 | margin: 0.5em 0;
27 | overflow: visible;
28 | padding: 0;
29 | }
30 |
31 | pre[class*='language-']>code {
32 | position: relative;
33 | }
34 |
35 | code[class*='language'] {
36 | border-radius: 3px;
37 | background: #faf8f5;
38 | max-height: inherit;
39 | height: inherit;
40 | padding: 1em;
41 | display: block;
42 | overflow: auto;
43 | margin: 0;
44 | }
45 |
46 | /* Inline code */
47 | :not(pre)>code[class*='language-'] {
48 | display: inline;
49 | position: relative;
50 | color: #c92c2c;
51 | padding: 0.15em;
52 | white-space: normal;
53 | }
54 |
55 | .token.comment,
56 | .token.block-comment,
57 | .token.prolog,
58 | .token.doctype,
59 | .token.cdata {
60 | color: #7d8b99;
61 | }
62 |
63 | .token.punctuation {
64 | color: #5f6364;
65 | }
66 |
67 | .token.property,
68 | .token.tag,
69 | .token.boolean,
70 | .token.number,
71 | .token.function-name,
72 | .token.constant,
73 | .token.symbol,
74 | .token.deleted {
75 | color: #c92c2c;
76 | }
77 |
78 | .token.selector,
79 | .token.attr-name,
80 | .token.string,
81 | .token.char,
82 | .token.function,
83 | .token.builtin,
84 | .token.inserted {
85 | color: #2f9c0a;
86 | }
87 |
88 | .token.operator,
89 | .token.entity,
90 | .token.url,
91 | .token.variable {
92 | color: #a67f59;
93 | background: rgba(255, 255, 255, 0.5);
94 | }
95 |
96 | .token.atrule,
97 | .token.attr-value,
98 | .token.keyword,
99 | .token.class-name {
100 | color: #1990b8;
101 | }
102 |
103 | .token.regex,
104 | .token.important {
105 | color: #e90;
106 | }
107 |
108 | .language-css .token.string,
109 | .style .token.string {
110 | color: #a67f59;
111 | background: rgba(255, 255, 255, 0.5);
112 | }
113 |
114 | .token.important {
115 | font-weight: normal;
116 | }
117 |
118 | .token.bold {
119 | font-weight: bold;
120 | }
121 |
122 | .token.italic {
123 | font-style: italic;
124 | }
125 |
126 | .token.entity {
127 | cursor: help;
128 | }
129 |
130 | .namespace {
131 | opacity: 0.7;
132 | }
133 |
134 | @media screen and (max-width: 767px) {
135 |
136 | pre[class*='language-']:before,
137 | pre[class*='language-']:after {
138 | bottom: 14px;
139 | box-shadow: none;
140 | }
141 | }
142 |
143 | /* Plugin styles */
144 | .token.tab:not(:empty):before,
145 | .token.cr:before,
146 | .token.lf:before {
147 | color: #e0d7d1;
148 | }
149 |
150 | /* Plugin styles: Line Numbers */
151 | pre[class*='language-'].line-numbers.line-numbers {
152 | padding-left: 0;
153 | }
154 |
155 | pre[class*='language-'].line-numbers.line-numbers code {
156 | padding-left: 3.8em;
157 | }
158 |
159 | pre[class*='language-'].line-numbers.line-numbers .line-numbers-rows {
160 | left: 0;
161 | }
162 |
163 | /* Plugin styles: Line Highlight */
164 | pre[class*='language-'][data-line] {
165 | padding-top: 0;
166 | padding-bottom: 0;
167 | padding-left: 0;
168 | }
169 |
170 | pre[data-line] code {
171 | position: relative;
172 | padding-left: 4em;
173 | }
174 |
175 | pre .line-highlight {
176 | margin-top: 0;
177 | }
--------------------------------------------------------------------------------
/src/utils/pubsub.ts:
--------------------------------------------------------------------------------
1 | import type { Arrow } from '../arrow'
2 | import type { Summary } from '../summary'
3 | import type { NodeObj } from '../types/index'
4 |
5 | type NodeOperation =
6 | | {
7 | name: 'moveNodeIn' | 'moveDownNode' | 'moveUpNode' | 'copyNode' | 'addChild' | 'insertParent' | 'insertBefore' | 'beginEdit'
8 | obj: NodeObj
9 | }
10 | | {
11 | name: 'insertSibling'
12 | type: 'before' | 'after'
13 | obj: NodeObj
14 | }
15 | | {
16 | name: 'reshapeNode'
17 | obj: NodeObj
18 | origin: NodeObj
19 | }
20 | | {
21 | name: 'finishEdit'
22 | obj: NodeObj
23 | origin: string
24 | }
25 | | {
26 | name: 'moveNodeAfter' | 'moveNodeBefore' | 'moveNodeIn'
27 | objs: NodeObj[]
28 | toObj: NodeObj
29 | }
30 |
31 | type MultipleNodeOperation =
32 | | {
33 | name: 'removeNodes'
34 | objs: NodeObj[]
35 | }
36 | | {
37 | name: 'copyNodes'
38 | objs: NodeObj[]
39 | }
40 |
41 | export type SummaryOperation =
42 | | {
43 | name: 'createSummary'
44 | obj: Summary
45 | }
46 | | {
47 | name: 'removeSummary'
48 | obj: { id: string }
49 | }
50 | | {
51 | name: 'finishEditSummary'
52 | obj: Summary
53 | }
54 |
55 | export type ArrowOperation =
56 | | {
57 | name: 'createArrow'
58 | obj: Arrow
59 | }
60 | | {
61 | name: 'removeArrow'
62 | obj: { id: string }
63 | }
64 | | {
65 | name: 'finishEditArrowLabel'
66 | obj: Arrow
67 | }
68 |
69 | export type Operation = NodeOperation | MultipleNodeOperation | SummaryOperation | ArrowOperation
70 | export type OperationType = Operation['name']
71 |
72 | export type EventMap = {
73 | operation: (info: Operation) => void
74 | selectNewNode: (nodeObj: NodeObj) => void
75 | selectNodes: (nodeObj: NodeObj[]) => void
76 | unselectNodes: (nodeObj: NodeObj[]) => void
77 | expandNode: (nodeObj: NodeObj) => void
78 | changeDirection: (direction: number) => void
79 | linkDiv: () => void
80 | scale: (scale: number) => void
81 | move: (data: { dx: number; dy: number }) => void
82 | /**
83 | * please use throttling to prevent performance degradation
84 | */
85 | updateArrowDelta: (arrow: Arrow) => void
86 | showContextMenu: (e: MouseEvent) => void
87 | }
88 |
89 | export function createBus void> = EventMap>() {
90 | return {
91 | handlers: {} as Record void)[]>,
92 | addListener: function (type: K, handler: T[K]) {
93 | if (this.handlers[type] === undefined) this.handlers[type] = []
94 | this.handlers[type].push(handler)
95 | },
96 | fire: function (type: K, ...payload: Parameters) {
97 | if (this.handlers[type] instanceof Array) {
98 | const handlers = this.handlers[type]
99 | for (let i = 0; i < handlers.length; i++) {
100 | handlers[i](...payload)
101 | }
102 | }
103 | },
104 | removeListener: function (type: K, handler: T[K]) {
105 | if (!this.handlers[type]) return
106 | const handlers = this.handlers[type]
107 | if (!handler) {
108 | handlers.length = 0
109 | } else if (handlers.length) {
110 | for (let i = 0; i < handlers.length; i++) {
111 | if (handlers[i] === handler) {
112 | this.handlers[type].splice(i, 1)
113 | }
114 | }
115 | }
116 | },
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/linkDiv.ts:
--------------------------------------------------------------------------------
1 | import { createPath, createLinkSvg } from './utils/svg'
2 | import { getOffsetLT } from './utils/index'
3 | import type { Wrapper, Topic } from './types/dom'
4 | import type { DirectionClass, MindElixirInstance } from './types/index'
5 |
6 | /**
7 | * Link nodes with svg,
8 | * only link specific node if `mainNode` is present
9 | *
10 | * procedure:
11 | * 1. generate main link
12 | * 2. generate links inside main node, if `mainNode` is presented, only generate the link of the specific main node
13 | * 3. generate custom link
14 | * 4. generate summary
15 | * @param mainNode regenerate sublink of the specific main node
16 | */
17 | const linkDiv = function (this: MindElixirInstance, mainNode?: Wrapper) {
18 | console.time('linkDiv')
19 |
20 | const root = this.map.querySelector('me-root') as HTMLElement
21 | const pT = root.offsetTop
22 | const pL = root.offsetLeft
23 | const pW = root.offsetWidth
24 | const pH = root.offsetHeight
25 |
26 | const mainNodeList = this.map.querySelectorAll('me-main > me-wrapper')
27 | this.lines.innerHTML = ''
28 |
29 | for (let i = 0; i < mainNodeList.length; i++) {
30 | const el = mainNodeList[i] as Wrapper
31 | const tpc = el.querySelector('me-tpc') as Topic
32 | const { offsetLeft: cL, offsetTop: cT } = getOffsetLT(this.nodes, tpc)
33 | const cW = tpc.offsetWidth
34 | const cH = tpc.offsetHeight
35 | const direction = el.parentNode.className as DirectionClass
36 |
37 | const mainPath = this.generateMainBranch({ pT, pL, pW, pH, cT, cL, cW, cH, direction, containerHeight: this.nodes.offsetHeight })
38 | const palette = this.theme.palette
39 | const branchColor = tpc.nodeObj.branchColor || palette[i % palette.length]
40 | tpc.style.borderColor = branchColor
41 | this.lines.appendChild(createPath(mainPath, branchColor, '3'))
42 |
43 | // generate link inside main node
44 | if (mainNode && mainNode !== el) {
45 | continue
46 | }
47 |
48 | const svg = createLinkSvg('subLines')
49 | // svg tag name is lower case
50 | const svgLine = el.lastChild as SVGSVGElement
51 | if (svgLine.tagName === 'svg') svgLine.remove()
52 | el.appendChild(svg)
53 |
54 | traverseChildren(this, svg, branchColor, el, direction, true)
55 | }
56 |
57 | this.labelContainer.innerHTML = ''
58 | this.renderArrow()
59 | this.renderSummary()
60 | console.timeEnd('linkDiv')
61 | this.bus.fire('linkDiv')
62 | }
63 |
64 | // core function of generate subLines
65 |
66 | const traverseChildren = function (
67 | mei: MindElixirInstance,
68 | svgContainer: SVGSVGElement,
69 | branchColor: string,
70 | wrapper: Wrapper,
71 | direction: DirectionClass,
72 | isFirst?: boolean
73 | ) {
74 | const parent = wrapper.firstChild
75 | const children = wrapper.children[1].children
76 | if (children.length === 0) return
77 |
78 | const pT = parent.offsetTop
79 | const pL = parent.offsetLeft
80 | const pW = parent.offsetWidth
81 | const pH = parent.offsetHeight
82 | for (let i = 0; i < children.length; i++) {
83 | const child = children[i]
84 | const childP = child.firstChild
85 | const cT = childP.offsetTop
86 | const cL = childP.offsetLeft
87 | const cW = childP.offsetWidth
88 | const cH = childP.offsetHeight
89 |
90 | const bc = childP.firstChild.nodeObj.branchColor || branchColor
91 | const path = mei.generateSubBranch({ pT, pL, pW, pH, cT, cL, cW, cH, direction, isFirst })
92 | svgContainer.appendChild(createPath(path, bc, '2'))
93 |
94 | const expander = childP.children[1]
95 |
96 | if (expander) {
97 | // this property is added in the layout phase
98 | if (!expander.expanded) continue
99 | } else {
100 | // expander not exist
101 | continue
102 | }
103 |
104 | traverseChildren(mei, svgContainer, bc, child, direction)
105 | }
106 | }
107 |
108 | export default linkDiv
109 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | parserPreset: 'conventional-changelog-conventionalcommits',
3 | rules: {
4 | 'body-leading-blank': [1, 'always'],
5 | 'body-max-line-length': [2, 'always', 120],
6 | 'footer-leading-blank': [1, 'always'],
7 | 'footer-max-line-length': [2, 'always', 100],
8 | 'header-max-length': [2, 'always', 100],
9 | 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']],
10 | 'subject-empty': [2, 'never'],
11 | 'subject-full-stop': [2, 'never', '.'],
12 | 'type-case': [2, 'always', 'lower-case'],
13 | 'type-empty': [2, 'never'],
14 | 'type-enum': [2, 'always', ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test']],
15 | },
16 | prompt: {
17 | questions: {
18 | type: {
19 | description: "Select the type of change that you're committing",
20 | enum: {
21 | feat: {
22 | description: 'A new feature',
23 | title: 'Features',
24 | emoji: '✨',
25 | },
26 | fix: {
27 | description: 'A bug fix',
28 | title: 'Bug Fixes',
29 | emoji: '🐛',
30 | },
31 | docs: {
32 | description: 'Documentation only changes',
33 | title: 'Documentation',
34 | emoji: '📚',
35 | },
36 | style: {
37 | description: 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',
38 | title: 'Styles',
39 | emoji: '💎',
40 | },
41 | refactor: {
42 | description: 'A code change that neither fixes a bug nor adds a feature',
43 | title: 'Code Refactoring',
44 | emoji: '📦',
45 | },
46 | perf: {
47 | description: 'A code change that improves performance',
48 | title: 'Performance Improvements',
49 | emoji: '🚀',
50 | },
51 | test: {
52 | description: 'Adding missing tests or correcting existing tests',
53 | title: 'Tests',
54 | emoji: '🚨',
55 | },
56 | build: {
57 | description: 'Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)',
58 | title: 'Builds',
59 | emoji: '🛠',
60 | },
61 | ci: {
62 | description: 'Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)',
63 | title: 'Continuous Integrations',
64 | emoji: '⚙️',
65 | },
66 | chore: {
67 | description: "Other changes that don't modify src or test files",
68 | title: 'Chores',
69 | emoji: '♻️',
70 | },
71 | revert: {
72 | description: 'Reverts a previous commit',
73 | title: 'Reverts',
74 | emoji: '🗑',
75 | },
76 | },
77 | },
78 | scope: {
79 | description: 'What is the scope of this change (e.g. component or file name)',
80 | },
81 | subject: {
82 | description: 'Write a short, imperative tense description of the change',
83 | },
84 | body: {
85 | description: 'Provide a longer description of the change',
86 | },
87 | isBreaking: {
88 | description: 'Are there any breaking changes?',
89 | },
90 | breakingBody: {
91 | description: 'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself',
92 | },
93 | breaking: {
94 | description: 'Describe the breaking changes',
95 | },
96 | isIssueAffected: {
97 | description: 'Does this change affect any open issues?',
98 | },
99 | issuesBody: {
100 | description: 'If issues are closed, the commit requires a body. Please enter a longer description of the commit itself',
101 | },
102 | issues: {
103 | description: 'Add issue references (e.g. "fix #123", "re #123".)',
104 | },
105 | },
106 | },
107 | }
108 |
--------------------------------------------------------------------------------
/tests/MindElixirFixture.ts:
--------------------------------------------------------------------------------
1 | import { type Page, type Locator, expect } from '@playwright/test'
2 | import type { MindElixirCtor, MindElixirData, MindElixirInstance, Options } from '../src'
3 | import type MindElixir from '../src'
4 | interface Window {
5 | m: MindElixirInstance
6 | MindElixir: MindElixirCtor
7 | E: typeof MindElixir.E
8 | }
9 | declare let window: Window
10 |
11 | export class MindElixirFixture {
12 | private m: MindElixirInstance
13 |
14 | constructor(public readonly page: Page) {
15 | //
16 | }
17 |
18 | async goto() {
19 | await this.page.goto('http://localhost:23334/test.html')
20 | }
21 | async init(data: MindElixirData, el = '#map') {
22 | // evaluate return Serializable value
23 | await this.page.evaluate(
24 | ({ data, el }) => {
25 | const MindElixir = window.MindElixir
26 | const options: Options = {
27 | el,
28 | direction: MindElixir.SIDE,
29 | allowUndo: true, // Enable undo/redo functionality for tests
30 | keypress: true, // Enable keyboard shortcuts
31 | editable: true, // Enable editing
32 | }
33 | const mind = new MindElixir(options)
34 | mind.init(JSON.parse(JSON.stringify(data)))
35 | window[el] = mind
36 | return mind
37 | },
38 | { data, el }
39 | )
40 | }
41 | async getInstance(el = '#map') {
42 | const instanceHandle = await this.page.evaluateHandle(el => Promise.resolve(window[el] as MindElixirInstance), el)
43 | return instanceHandle
44 | }
45 | async getData(el = '#map') {
46 | const data = await this.page.evaluate(el => {
47 | return window[el].getData()
48 | }, el)
49 | // console.log(a)
50 | // const dataHandle = await this.page.evaluateHandle(() => Promise.resolve(window.m.getData()))
51 | // const data = await dataHandle.jsonValue()
52 | return data
53 | }
54 | async dblclick(topic: string) {
55 | await this.page.getByText(topic, { exact: true }).dblclick({
56 | force: true,
57 | })
58 | }
59 | async click(topic: string) {
60 | await this.page.getByText(topic, { exact: true }).click({
61 | force: true,
62 | })
63 | }
64 | getByText(topic: string) {
65 | return this.page.getByText(topic, { exact: true })
66 | }
67 | async dragOver(topic: string, type: 'before' | 'after' | 'in') {
68 | await this.page.getByText(topic).hover({ force: true })
69 | await this.page.mouse.down()
70 | const target = await this.page.getByText(topic)
71 | const box = (await target.boundingBox())!
72 | const y = type === 'before' ? -12 : type === 'after' ? box.height + 12 : box.height / 2
73 | // https://playwright.dev/docs/input#dragging-manually
74 | // If your page relies on the dragover event being dispatched, you need at least two mouse moves to trigger it in all browsers.
75 | await this.page.mouse.move(box.x + box.width / 2, box.y + y)
76 | await this.page.waitForTimeout(100) // throttle
77 | await this.page.mouse.move(box.x + box.width / 2, box.y + y)
78 | }
79 | async dragSelect(topic1: string, topic2: string) {
80 | // Get the bounding boxes for both topics
81 | const element1 = this.page.getByText(topic1, { exact: true })
82 | const element2 = this.page.getByText(topic2, { exact: true })
83 |
84 | const box1 = await element1.boundingBox()
85 | const box2 = await element2.boundingBox()
86 |
87 | if (!box1 || !box2) {
88 | throw new Error(`Could not find bounding box for topics: ${topic1}, ${topic2}`)
89 | }
90 |
91 | // Calculate the selection area coordinates
92 | // Find the minimum and maximum x, y coordinates
93 | const minX = Math.min(box1.x, box2.x) - 10
94 | const minY = Math.min(box1.y, box2.y) - 10
95 | const maxX = Math.max(box1.x + box1.width, box2.x + box2.width) + 10
96 | const maxY = Math.max(box1.y + box1.height, box2.y + box2.height) + 10
97 |
98 | // Perform the drag selection
99 | await this.page.mouse.move(minX, minY)
100 | await this.page.mouse.down()
101 | await this.page.waitForTimeout(100) // throttle
102 | await this.page.mouse.move(maxX, maxY)
103 | await this.page.mouse.up()
104 | }
105 | async toHaveScreenshot(locator?: Locator) {
106 | await expect(locator || this.page.locator('me-nodes')).toHaveScreenshot({
107 | maxDiffPixelRatio: 0.02,
108 | })
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/plugin/operationHistory.ts:
--------------------------------------------------------------------------------
1 | import type { MindElixirData, NodeObj, OperationType } from '../index'
2 | import { type MindElixirInstance } from '../index'
3 | import type { Operation } from '../utils/pubsub'
4 |
5 | type History = {
6 | prev: MindElixirData
7 | next: MindElixirData
8 | currentSelected: string[]
9 | operation: OperationType
10 | currentTarget:
11 | | {
12 | type: 'summary' | 'arrow'
13 | value: string
14 | }
15 | | {
16 | type: 'nodes'
17 | value: string[]
18 | }
19 | }
20 |
21 | const calcCurentObject = function (operation: Operation): History['currentTarget'] {
22 | if (['createSummary', 'removeSummary', 'finishEditSummary'].includes(operation.name)) {
23 | return {
24 | type: 'summary',
25 | value: (operation as any).obj.id,
26 | }
27 | } else if (['createArrow', 'removeArrow', 'finishEditArrowLabel'].includes(operation.name)) {
28 | return {
29 | type: 'arrow',
30 | value: (operation as any).obj.id,
31 | }
32 | } else if (['removeNodes', 'copyNodes', 'moveNodeBefore', 'moveNodeAfter', 'moveNodeIn'].includes(operation.name)) {
33 | return {
34 | type: 'nodes',
35 | value: (operation as any).objs.map((obj: NodeObj) => obj.id),
36 | }
37 | } else {
38 | return {
39 | type: 'nodes',
40 | value: [(operation as any).obj.id],
41 | }
42 | }
43 | }
44 |
45 | export default function (mei: MindElixirInstance) {
46 | let history = [] as History[]
47 | let currentIndex = -1
48 | let current = mei.getData()
49 | let currentSelectedNodes: NodeObj[] = []
50 | mei.undo = function () {
51 | // 操作是删除时,undo 恢复内容,应选中操作的目标
52 | // 操作是新增时,undo 删除内容,应选中当前选中节点
53 | if (currentIndex > -1) {
54 | const h = history[currentIndex]
55 | current = h.prev
56 | mei.refresh(h.prev)
57 | try {
58 | if (h.currentTarget.type === 'nodes') {
59 | if (h.operation === 'removeNodes') {
60 | mei.selectNodes(h.currentTarget.value.map(id => this.findEle(id)))
61 | } else {
62 | mei.selectNodes(h.currentSelected.map(id => this.findEle(id)))
63 | }
64 | }
65 | } catch (e) {
66 | // undo add node cause node not found
67 | } finally {
68 | currentIndex--
69 | }
70 | }
71 | }
72 | mei.redo = function () {
73 | if (currentIndex < history.length - 1) {
74 | currentIndex++
75 | const h = history[currentIndex]
76 | current = h.next
77 | mei.refresh(h.next)
78 | try {
79 | if (h.currentTarget.type === 'nodes') {
80 | if (h.operation === 'removeNodes') {
81 | mei.selectNodes(h.currentSelected.map(id => this.findEle(id)))
82 | } else {
83 | mei.selectNodes(h.currentTarget.value.map(id => this.findEle(id)))
84 | }
85 | }
86 | } catch (e) {
87 | // redo delete node cause node not found
88 | }
89 | }
90 | }
91 | const handleOperation = function (operation: Operation) {
92 | if (operation.name === 'beginEdit') return
93 | history = history.slice(0, currentIndex + 1)
94 | const next = mei.getData()
95 | const item = {
96 | prev: current,
97 | operation: operation.name,
98 | currentSelected: currentSelectedNodes.map(n => n.id),
99 | currentTarget: calcCurentObject(operation),
100 | next,
101 | }
102 | history.push(item)
103 | current = next
104 | currentIndex = history.length - 1
105 | console.log('operation', item.currentSelected, item.currentTarget.value)
106 | }
107 | const handleKeyDown = function (e: KeyboardEvent) {
108 | // console.log(`mei.map.addEventListener('keydown', handleKeyDown)`, e.key, history.length, currentIndex)
109 | if ((e.metaKey || e.ctrlKey) && ((e.shiftKey && e.key === 'Z') || e.key === 'y')) mei.redo()
110 | else if ((e.metaKey || e.ctrlKey) && e.key === 'z') mei.undo()
111 | }
112 | const handleSelectNodes = function () {
113 | currentSelectedNodes = mei.currentNodes.map(n => n.nodeObj)
114 | }
115 | mei.bus.addListener('operation', handleOperation)
116 | mei.bus.addListener('selectNodes', handleSelectNodes)
117 | mei.container.addEventListener('keydown', handleKeyDown)
118 | return () => {
119 | mei.bus.removeListener('operation', handleOperation)
120 | mei.bus.removeListener('selectNodes', handleSelectNodes)
121 | mei.container.removeEventListener('keydown', handleKeyDown)
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/methods.ts:
--------------------------------------------------------------------------------
1 | import type { MindElixirInstance, MindElixirData } from './index'
2 | import linkDiv from './linkDiv'
3 | import contextMenu from './plugin/contextMenu'
4 | import keypressInit from './plugin/keypress'
5 | import nodeDraggable from './plugin/nodeDraggable'
6 | import operationHistory from './plugin/operationHistory'
7 | import toolBar from './plugin/toolBar'
8 | import selection from './plugin/selection'
9 | import { editTopic, createWrapper, createParent, createChildren, createTopic, findEle } from './utils/dom'
10 | import { getObjById, generateNewObj, fillParent } from './utils/index'
11 | import { layout } from './utils/layout'
12 | import { changeTheme } from './utils/theme'
13 | import * as interact from './interact'
14 | import * as nodeOperation from './nodeOperation'
15 | import * as arrow from './arrow'
16 | import * as summary from './summary'
17 | import * as exportImage from './plugin/exportImage'
18 |
19 | export type OperationMap = typeof nodeOperation
20 | export type Operations = keyof OperationMap
21 | type NodeOperation = {
22 | [K in Operations]: ReturnType>
23 | }
24 |
25 | function beforeHook(
26 | fn: OperationMap[T],
27 | fnName: T
28 | ): (this: MindElixirInstance, ...args: Parameters) => Promise {
29 | return async function (this: MindElixirInstance, ...args: Parameters) {
30 | const hook = this.before[fnName]
31 | if (hook) {
32 | const res = await hook.apply(this, args)
33 | if (!res) return
34 | }
35 | ;(fn as any).apply(this, args)
36 | }
37 | }
38 |
39 | const operations = Object.keys(nodeOperation) as Array
40 | const nodeOperationHooked = {} as NodeOperation
41 | if (import.meta.env.MODE !== 'lite') {
42 | for (let i = 0; i < operations.length; i++) {
43 | const operation = operations[i]
44 | nodeOperationHooked[operation] = beforeHook(nodeOperation[operation], operation)
45 | }
46 | }
47 |
48 | export type MindElixirMethods = typeof methods
49 |
50 | /**
51 | * Methods that mind-elixir instance can use
52 | *
53 | * @public
54 | */
55 | const methods = {
56 | getObjById,
57 | generateNewObj,
58 | layout,
59 | linkDiv,
60 | editTopic,
61 | createWrapper,
62 | createParent,
63 | createChildren,
64 | createTopic,
65 | findEle,
66 | changeTheme,
67 | ...interact,
68 | ...(nodeOperationHooked as NodeOperation),
69 | ...arrow,
70 | ...summary,
71 | ...exportImage,
72 | init(this: MindElixirInstance, data: MindElixirData) {
73 | data = JSON.parse(JSON.stringify(data))
74 | if (!data || !data.nodeData) return new Error('MindElixir: `data` is required')
75 | if (data.direction !== undefined) {
76 | this.direction = data.direction
77 | }
78 | this.changeTheme(data.theme || this.theme, false)
79 | this.nodeData = data.nodeData
80 | fillParent(this.nodeData)
81 | this.arrows = data.arrows || []
82 | this.summaries = data.summaries || []
83 | this.tidyArrow()
84 | // plugins
85 | this.toolBar && toolBar(this)
86 | if (import.meta.env.MODE !== 'lite') {
87 | this.keypress && keypressInit(this, this.keypress)
88 |
89 | if (this.editable) {
90 | selection(this)
91 | }
92 | if (this.contextMenu) {
93 | this.disposable.push(contextMenu(this, this.contextMenu))
94 | }
95 | this.draggable && this.disposable.push(nodeDraggable(this))
96 | this.allowUndo && this.disposable.push(operationHistory(this))
97 | }
98 | this.layout()
99 | this.linkDiv()
100 | this.toCenter()
101 | },
102 | destroy(this: Partial) {
103 | this.disposable!.forEach(fn => fn())
104 | if (this.el) this.el.innerHTML = ''
105 | this.el = undefined
106 | this.nodeData = undefined
107 | this.arrows = undefined
108 | this.summaries = undefined
109 | this.currentArrow = undefined
110 | this.currentNodes = undefined
111 | this.currentSummary = undefined
112 | this.waitCopy = undefined
113 | this.theme = undefined
114 | this.direction = undefined
115 | this.bus = undefined
116 | this.container = undefined
117 | this.map = undefined
118 | this.lines = undefined
119 | this.linkController = undefined
120 | this.linkSvgGroup = undefined
121 | this.P2 = undefined
122 | this.P3 = undefined
123 | this.line1 = undefined
124 | this.line2 = undefined
125 | this.nodes = undefined
126 | this.selection?.destroy()
127 | this.selection = undefined
128 | },
129 | }
130 |
131 | export default methods
132 |
--------------------------------------------------------------------------------
/src/exampleData/2.ts:
--------------------------------------------------------------------------------
1 | import type { MindElixirData } from '../index'
2 | import MindElixir from '../index'
3 |
4 | const mindElixirStruct: MindElixirData = {
5 | direction: 1,
6 | theme: MindElixir.DARK_THEME,
7 | nodeData: {
8 | id: 'me-root',
9 | topic: 'HTML structure',
10 | children: [
11 | {
12 | topic: 'div.map-container',
13 | id: '33905a6bde6512e4',
14 | expanded: true,
15 | children: [
16 | {
17 | topic: 'div.map-canvas',
18 | id: '33905d3c66649e8f',
19 | tags: ['A special case of a `grp` tag'],
20 | expanded: true,
21 | children: [
22 | {
23 | topic: 'me-root',
24 | id: '33906b754897b9b9',
25 | tags: ['A special case of a `t` tag'],
26 | expanded: true,
27 | children: [{ topic: 'ME-TPC', id: '33b5cbc93b9968ab' }],
28 | },
29 | {
30 | topic: 'children.box',
31 | id: '33906db16ed7f956',
32 | expanded: true,
33 | children: [
34 | {
35 | topic: 'grp(group)',
36 | id: '33907d9a3664cc8a',
37 | expanded: true,
38 | children: [
39 | {
40 | topic: 't(top)',
41 | id: '3390856d09415b95',
42 | expanded: true,
43 | children: [
44 | {
45 | topic: 'tpc(topic)',
46 | id: '33908dd36c7d32c5',
47 | expanded: true,
48 | children: [
49 | { topic: 'text', id: '3391630d4227e248' },
50 | { topic: 'icons', id: '33916d74224b141f' },
51 | { topic: 'tags', id: '33916421bfff1543' },
52 | ],
53 | tags: ['E() function return'],
54 | },
55 | {
56 | topic: 'epd(expander)',
57 | id: '33909032ed7b5e8e',
58 | tags: ['If had child'],
59 | },
60 | ],
61 | tags: ['createParent retun'],
62 | },
63 | {
64 | topic: 'me-children',
65 | id: '339087e1a8a5ea68',
66 | expanded: true,
67 | children: [
68 | {
69 | topic: 'me-wrapper',
70 | id: '3390930112ea7367',
71 | tags: ['what add node actually do is to append grp tag to children'],
72 | },
73 | { topic: 'grp...', id: '3390940a8c8380a6' },
74 | ],
75 | tags: ['layoutChildren return'],
76 | },
77 | { topic: 'svg.subLines', id: '33908986b6336a4f' },
78 | ],
79 | tags: ['have child'],
80 | },
81 | {
82 | topic: 'me-wrapper',
83 | id: '339081c3c5f57756',
84 | expanded: true,
85 | children: [
86 | {
87 | topic: 'ME-PARENT',
88 | id: '33b6160ec048b997',
89 | expanded: true,
90 | children: [{ topic: 'ME-TPC', id: '33b616f9fe7763fc' }],
91 | },
92 | ],
93 | tags: ['no child'],
94 | },
95 | { topic: 'grp...', id: '33b61346707af71a' },
96 | ],
97 | },
98 | { topic: 'svg.lines', id: '3390707d68c0779d' },
99 | { topic: 'svg.linkcontroller', id: '339072cb6cf95295' },
100 | { topic: 'svg.topiclinks', id: '3390751acbdbdb9f' },
101 | ],
102 | },
103 | { topic: 'cmenu', id: '33905f95aeab942d' },
104 | { topic: 'toolbar.rb', id: '339060ac0343f0d7' },
105 | { topic: 'toolbar.lt', id: '3390622b29323de9' },
106 | { topic: 'nmenu', id: '3390645e6d7c2b4e' },
107 | ],
108 | },
109 | ],
110 | },
111 | arrows: [],
112 | }
113 |
114 | export default mindElixirStruct
115 |
--------------------------------------------------------------------------------
/tests/simple-undo-redo.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from './mind-elixir-test'
2 |
3 | const data = {
4 | nodeData: {
5 | topic: 'Root',
6 | id: 'root',
7 | children: [
8 | {
9 | id: 'child1',
10 | topic: 'Child 1',
11 | },
12 | ],
13 | },
14 | }
15 |
16 | test.beforeEach(async ({ me }) => {
17 | await me.init(data)
18 | })
19 |
20 | test('Simple Undo/Redo - Basic Add Node', async ({ page, me }) => {
21 | // Add a node
22 | await me.click('Child 1')
23 | await page.keyboard.press('Enter')
24 | await page.keyboard.press('Enter')
25 | await expect(page.getByText('New Node')).toBeVisible()
26 |
27 | // Test Ctrl+Z (undo)
28 | await page.keyboard.press('Control+z')
29 | await expect(page.getByText('New Node')).toBeHidden()
30 |
31 | // Test Ctrl+Y (redo)
32 | await page.keyboard.press('Control+y')
33 | await expect(page.getByText('New Node')).toBeVisible()
34 | })
35 |
36 | test('Simple Undo/Redo - Basic Remove Node', async ({ page, me }) => {
37 | // Remove a node
38 | await me.click('Child 1')
39 | await page.keyboard.press('Delete')
40 | await expect(page.getByText('Child 1')).toBeHidden()
41 |
42 | // Test Ctrl+Z (undo)
43 | await page.keyboard.press('Control+z')
44 | await expect(page.getByText('Child 1')).toBeVisible()
45 |
46 | // Test Ctrl+Y (redo)
47 | await page.keyboard.press('Control+y')
48 | await expect(page.getByText('Child 1')).toBeHidden()
49 | })
50 |
51 | test('Simple Undo/Redo - Test Ctrl+Shift+Z', async ({ page, me }) => {
52 | // Add a node
53 | await me.click('Child 1')
54 | await page.keyboard.press('Tab') // Add child
55 | await page.keyboard.press('Enter')
56 | await expect(page.getByText('New Node')).toBeVisible()
57 |
58 | // Undo
59 | await page.keyboard.press('Control+z')
60 | await expect(page.getByText('New Node')).toBeHidden()
61 |
62 | // Try Ctrl+Shift+Z for redo
63 | await page.keyboard.press('Control+Shift+Z')
64 | await page.waitForTimeout(500)
65 |
66 | const nodeVisible = await page.getByText('New Node').isVisible()
67 |
68 | // If that didn't work, try lowercase z
69 | if (!nodeVisible) {
70 | await page.keyboard.press('Control+Shift+Z')
71 | await page.waitForTimeout(500)
72 | const nodeVisible2 = await page.getByText('New Node').isVisible()
73 | console.log('Node visible after Ctrl+Shift+z:', nodeVisible2)
74 | }
75 | })
76 |
77 | test('Simple Undo/Redo - Test Meta Keys', async ({ page, me }) => {
78 | // Add a node
79 | await me.click('Root')
80 | await page.keyboard.press('Tab')
81 | await page.keyboard.press('Enter')
82 | await expect(page.getByText('New Node')).toBeVisible()
83 |
84 | // Test Meta+Z (Mac style undo)
85 | await page.keyboard.press('Meta+z')
86 | await expect(page.getByText('New Node')).toBeHidden()
87 |
88 | // Test Meta+Y (Mac style redo)
89 | await page.keyboard.press('Meta+y')
90 | await expect(page.getByText('New Node')).toBeVisible()
91 | })
92 |
93 | test('Simple Undo/Redo - Multiple Operations', async ({ page, me }) => {
94 | // Operation 1: Add child
95 | await me.click('Child 1')
96 | await page.keyboard.press('Tab')
97 | await page.keyboard.press('Enter')
98 | await expect(page.getByText('New Node')).toBeVisible()
99 |
100 | // Operation 2: Add sibling
101 | await page.keyboard.press('Enter')
102 | await page.keyboard.press('Enter')
103 | const newNodes = page.getByText('New Node')
104 | await expect(newNodes).toHaveCount(2)
105 |
106 | // Undo twice
107 | await page.keyboard.press('Control+z')
108 | await expect(newNodes).toHaveCount(1)
109 |
110 | await page.keyboard.press('Control+z')
111 | await expect(newNodes).toHaveCount(0)
112 |
113 | // Redo twice
114 | await page.keyboard.press('Control+y')
115 | await expect(newNodes).toHaveCount(1)
116 |
117 | await page.keyboard.press('Control+y')
118 | await expect(newNodes).toHaveCount(2)
119 | })
120 |
121 | test('Simple Undo/Redo - Edit Node', async ({ page, me }) => {
122 | // Edit a node
123 | await me.dblclick('Child 1')
124 | await page.keyboard.press('Control+a')
125 | await page.keyboard.insertText('Modified Child')
126 | await page.keyboard.press('Enter')
127 | await expect(page.getByText('Modified Child')).toBeVisible()
128 |
129 | // Undo edit
130 | await page.keyboard.press('Control+z')
131 | await expect(page.getByText('Child 1')).toBeVisible()
132 | await expect(page.getByText('Modified Child')).toBeHidden()
133 |
134 | // Redo edit
135 | await page.keyboard.press('Control+y')
136 | await expect(page.getByText('Modified Child')).toBeVisible()
137 | await expect(page.getByText('Child 1')).toBeHidden()
138 | })
139 |
--------------------------------------------------------------------------------
/src/i18n.ts:
--------------------------------------------------------------------------------
1 | type LangPack = {
2 | addChild: string
3 | addParent: string
4 | addSibling: string
5 | removeNode: string
6 | focus: string
7 | cancelFocus: string
8 | moveUp: string
9 | moveDown: string
10 | link: string
11 | linkBidirectional: string
12 | clickTips: string
13 | summary: string
14 | }
15 |
16 | /**
17 | * @public
18 | */
19 | export type Locale = 'cn' | 'zh_CN' | 'zh_TW' | 'en' | 'ru' | 'ja' | 'pt' | 'it' | 'es' | 'fr' | 'ko' | 'ro'
20 | const cn = {
21 | addChild: '插入子节点',
22 | addParent: '插入父节点',
23 | addSibling: '插入同级节点',
24 | removeNode: '删除节点',
25 | focus: '专注',
26 | cancelFocus: '取消专注',
27 | moveUp: '上移',
28 | moveDown: '下移',
29 | link: '连接',
30 | linkBidirectional: '双向连接',
31 | clickTips: '请点击目标节点',
32 | summary: '摘要',
33 | }
34 | const i18n: Record = {
35 | cn,
36 | zh_CN: cn,
37 | zh_TW: {
38 | addChild: '插入子節點',
39 | addParent: '插入父節點',
40 | addSibling: '插入同級節點',
41 | removeNode: '刪除節點',
42 | focus: '專注',
43 | cancelFocus: '取消專注',
44 | moveUp: '上移',
45 | moveDown: '下移',
46 | link: '連接',
47 | linkBidirectional: '雙向連接',
48 | clickTips: '請點擊目標節點',
49 | summary: '摘要',
50 | },
51 | en: {
52 | addChild: 'Add child',
53 | addParent: 'Add parent',
54 | addSibling: 'Add sibling',
55 | removeNode: 'Remove node',
56 | focus: 'Focus Mode',
57 | cancelFocus: 'Cancel Focus Mode',
58 | moveUp: 'Move up',
59 | moveDown: 'Move down',
60 | link: 'Link',
61 | linkBidirectional: 'Bidirectional Link',
62 | clickTips: 'Please click the target node',
63 | summary: 'Summary',
64 | },
65 | ru: {
66 | addChild: 'Добавить дочерний элемент',
67 | addParent: 'Добавить родительский элемент',
68 | addSibling: 'Добавить на этом уровне',
69 | removeNode: 'Удалить узел',
70 | focus: 'Режим фокусировки',
71 | cancelFocus: 'Отменить режим фокусировки',
72 | moveUp: 'Поднять выше',
73 | moveDown: 'Опустить ниже',
74 | link: 'Ссылка',
75 | linkBidirectional: 'Двунаправленная ссылка',
76 | clickTips: 'Пожалуйста, нажмите на целевой узел',
77 | summary: 'Описание',
78 | },
79 | ja: {
80 | addChild: '子ノードを追加する',
81 | addParent: '親ノードを追加します',
82 | addSibling: '兄弟ノードを追加する',
83 | removeNode: 'ノードを削除',
84 | focus: '集中',
85 | cancelFocus: '集中解除',
86 | moveUp: '上へ移動',
87 | moveDown: '下へ移動',
88 | link: 'コネクト',
89 | linkBidirectional: '双方向リンク',
90 | clickTips: 'ターゲットノードをクリックしてください',
91 | summary: '概要',
92 | },
93 | pt: {
94 | addChild: 'Adicionar item filho',
95 | addParent: 'Adicionar item pai',
96 | addSibling: 'Adicionar item irmao',
97 | removeNode: 'Remover item',
98 | focus: 'Modo Foco',
99 | cancelFocus: 'Cancelar Modo Foco',
100 | moveUp: 'Mover para cima',
101 | moveDown: 'Mover para baixo',
102 | link: 'Link',
103 | linkBidirectional: 'Link bidirecional',
104 | clickTips: 'Favor clicar no item alvo',
105 | summary: 'Resumo',
106 | },
107 | it: {
108 | addChild: 'Aggiungi figlio',
109 | addParent: 'Aggiungi genitore',
110 | addSibling: 'Aggiungi fratello',
111 | removeNode: 'Rimuovi nodo',
112 | focus: 'Modalità Focus',
113 | cancelFocus: 'Annulla Modalità Focus',
114 | moveUp: 'Sposta su',
115 | moveDown: 'Sposta giù',
116 | link: 'Collega',
117 | linkBidirectional: 'Collegamento bidirezionale',
118 | clickTips: 'Si prega di fare clic sul nodo di destinazione',
119 | summary: 'Unisci nodi',
120 | },
121 | es: {
122 | addChild: 'Agregar hijo',
123 | addParent: 'Agregar padre',
124 | addSibling: 'Agregar hermano',
125 | removeNode: 'Eliminar nodo',
126 | focus: 'Modo Enfoque',
127 | cancelFocus: 'Cancelar Modo Enfoque',
128 | moveUp: 'Mover hacia arriba',
129 | moveDown: 'Mover hacia abajo',
130 | link: 'Enlace',
131 | linkBidirectional: 'Enlace bidireccional',
132 | clickTips: 'Por favor haga clic en el nodo de destino',
133 | summary: 'Resumen',
134 | },
135 | fr: {
136 | addChild: 'Ajout enfant',
137 | addParent: 'Ajout parent',
138 | addSibling: 'Ajout voisin',
139 | removeNode: 'Supprimer',
140 | focus: 'Cibler',
141 | cancelFocus: 'Retour',
142 | moveUp: 'Monter',
143 | moveDown: 'Descendre',
144 | link: 'Lier',
145 | linkBidirectional: 'Lien bidirectionnel',
146 | clickTips: 'Cliquer sur le noeud cible',
147 | summary: 'Annoter',
148 | },
149 | ko: {
150 | addChild: '자식 추가',
151 | addParent: '부모 추가',
152 | addSibling: '형제 추가',
153 | removeNode: '노드 삭제',
154 | focus: '포커스 모드',
155 | cancelFocus: '포커스 모드 취소',
156 | moveUp: '위로 이동',
157 | moveDown: '아래로 이동',
158 | link: '연결',
159 | linkBidirectional: '양방향 연결',
160 | clickTips: '대상 노드를 클릭하십시오',
161 | summary: '요약',
162 | },
163 | ro: {
164 | addChild: 'Adaugă sub-nod',
165 | addParent: 'Adaugă nod părinte',
166 | addSibling: 'Adaugă nod la același nivel',
167 | removeNode: 'Șterge nodul',
168 | focus: 'Focalizare',
169 | cancelFocus: 'Anulează focalizarea',
170 | moveUp: 'Mută în sus',
171 | moveDown: 'Mută în jos',
172 | link: 'Creează legătură',
173 | linkBidirectional: 'Creează legătură bidirecțională',
174 | clickTips: 'Click pe nodul țintă',
175 | summary: 'Rezumat',
176 | },
177 | }
178 |
179 | export default i18n
180 |
--------------------------------------------------------------------------------
/src/plugin/nodeDraggable.ts:
--------------------------------------------------------------------------------
1 | import type { Topic } from '../types/dom'
2 | import type { MindElixirInstance } from '../types/index'
3 | import { on } from '../utils'
4 | // https://html.spec.whatwg.org/multipage/dnd.html#drag-and-drop-processing-model
5 | type InsertType = 'before' | 'after' | 'in' | null
6 | const $d = document
7 | const insertPreview = function (tpc: Topic, insertTpye: InsertType) {
8 | if (!insertTpye) {
9 | clearPreview(tpc)
10 | return tpc
11 | }
12 | let el = tpc.querySelector('.insert-preview')
13 | const className = `insert-preview ${insertTpye} show`
14 | if (!el) {
15 | el = $d.createElement('div')
16 | tpc.appendChild(el)
17 | }
18 | el.className = className
19 | return tpc
20 | }
21 |
22 | const clearPreview = function (el: Element | null) {
23 | if (!el) return
24 | const query = el.querySelectorAll('.insert-preview')
25 | for (const queryElement of query || []) {
26 | queryElement.remove()
27 | }
28 | }
29 |
30 | const canMove = function (el: Element, dragged: Topic[]) {
31 | for (const node of dragged) {
32 | const isContain = node.parentElement.parentElement.contains(el)
33 | const ok = el && el.tagName === 'ME-TPC' && el !== node && !isContain && (el as Topic).nodeObj.parent
34 | if (!ok) return false
35 | }
36 | return true
37 | }
38 |
39 | const createGhost = function (mei: MindElixirInstance) {
40 | const ghost = document.createElement('div')
41 | ghost.className = 'mind-elixir-ghost'
42 | mei.container.appendChild(ghost)
43 | return ghost
44 | }
45 |
46 | class EdgeMoveController {
47 | private mind: MindElixirInstance
48 | private isMoving = false
49 | private interval: NodeJS.Timeout | null = null
50 | private speed = 20
51 | constructor(mind: MindElixirInstance) {
52 | this.mind = mind
53 | }
54 | move(dx: number, dy: number) {
55 | if (this.isMoving) return
56 | this.isMoving = true
57 | this.interval = setInterval(() => {
58 | this.mind.move(dx * this.speed * this.mind.scaleVal, dy * this.speed * this.mind.scaleVal)
59 | }, 100)
60 | }
61 | stop() {
62 | this.isMoving = false
63 | clearInterval(this.interval!)
64 | }
65 | }
66 |
67 | export default function (mind: MindElixirInstance) {
68 | let insertTpye: InsertType = null
69 | let meet: Topic | null = null
70 | const ghost = createGhost(mind)
71 | const edgeMoveController = new EdgeMoveController(mind)
72 |
73 | const handleDragStart = (e: DragEvent) => {
74 | // 当按下空格键时,阻止节点拖拽
75 | if (mind.spacePressed) {
76 | e.preventDefault()
77 | return
78 | }
79 |
80 | mind.selection.cancel()
81 | const target = e.target as Topic
82 | if (target?.tagName !== 'ME-TPC') {
83 | // it should be a topic element, return if not
84 | e.preventDefault()
85 | return
86 | }
87 | let nodes = mind.currentNodes
88 | if (!nodes?.includes(target)) {
89 | mind.selectNode(target)
90 | nodes = mind.currentNodes
91 | }
92 | mind.dragged = nodes
93 | if (nodes.length > 1) ghost.innerHTML = nodes.length + ''
94 | else ghost.innerHTML = target.innerHTML
95 |
96 | for (const node of nodes) {
97 | node.parentElement.parentElement.style.opacity = '0.5'
98 | }
99 | e.dataTransfer!.setDragImage(ghost, 0, 0)
100 | e.dataTransfer!.dropEffect = 'move'
101 | mind.dragMoveHelper.clear()
102 | }
103 | const handleDragEnd = (e: DragEvent) => {
104 | const { dragged } = mind
105 | if (!dragged) return
106 | edgeMoveController.stop()
107 | for (const node of dragged) {
108 | node.parentElement.parentElement.style.opacity = '1'
109 | }
110 | const target = e.target as Topic
111 | target.style.opacity = ''
112 | if (!meet) return
113 | clearPreview(meet)
114 | if (insertTpye === 'before') {
115 | mind.moveNodeBefore(dragged, meet)
116 | } else if (insertTpye === 'after') {
117 | mind.moveNodeAfter(dragged, meet)
118 | } else if (insertTpye === 'in') {
119 | mind.moveNodeIn(dragged, meet)
120 | }
121 | mind.dragged = null
122 | ghost.innerHTML = ''
123 | }
124 | const handleDragOver = (e: DragEvent) => {
125 | e.preventDefault()
126 | const threshold = 12 * mind.scaleVal
127 | const { dragged } = mind
128 |
129 | if (!dragged) return
130 |
131 | // border detection
132 | const rect = mind.container.getBoundingClientRect()
133 | if (e.clientX < rect.x + 50) {
134 | edgeMoveController.move(1, 0)
135 | } else if (e.clientX > rect.x + rect.width - 50) {
136 | edgeMoveController.move(-1, 0)
137 | } else if (e.clientY < rect.y + 50) {
138 | edgeMoveController.move(0, 1)
139 | } else if (e.clientY > rect.y + rect.height - 50) {
140 | edgeMoveController.move(0, -1)
141 | } else {
142 | edgeMoveController.stop()
143 | }
144 |
145 | clearPreview(meet)
146 | // minus threshold infer that postion of the cursor is above topic
147 | const topMeet = $d.elementFromPoint(e.clientX, e.clientY - threshold) as Topic
148 | if (canMove(topMeet, dragged)) {
149 | meet = topMeet
150 | const rect = topMeet.getBoundingClientRect()
151 | const y = rect.y
152 | if (e.clientY > y + rect.height) {
153 | insertTpye = 'after'
154 | } else {
155 | insertTpye = 'in'
156 | }
157 | } else {
158 | const bottomMeet = $d.elementFromPoint(e.clientX, e.clientY + threshold) as Topic
159 | const rect = bottomMeet.getBoundingClientRect()
160 | if (canMove(bottomMeet, dragged)) {
161 | meet = bottomMeet
162 | const y = rect.y
163 | if (e.clientY < y) {
164 | insertTpye = 'before'
165 | } else {
166 | insertTpye = 'in'
167 | }
168 | } else {
169 | insertTpye = meet = null
170 | }
171 | }
172 | if (meet) insertPreview(meet, insertTpye)
173 | }
174 | const off = on([
175 | { dom: mind.map, evt: 'dragstart', func: handleDragStart },
176 | { dom: mind.map, evt: 'dragend', func: handleDragEnd },
177 | { dom: mind.map, evt: 'dragover', func: handleDragOver },
178 | ])
179 |
180 | return off
181 | }
182 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import type { Topic } from '../types/dom'
2 | import type { NodeObj, MindElixirInstance, NodeObjExport } from '../types/index'
3 |
4 | export function encodeHTML(s: string) {
5 | return s.replace(/&/g, '&').replace(/ /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
9 |
10 | export const getObjById = function (id: string, data: NodeObj): NodeObj | null {
11 | if (data.id === id) {
12 | return data
13 | } else if (data.children && data.children.length) {
14 | for (let i = 0; i < data.children.length; i++) {
15 | const res = getObjById(id, data.children[i])
16 | if (res) return res
17 | }
18 | return null
19 | } else {
20 | return null
21 | }
22 | }
23 |
24 | /**
25 | * Add parent property to every node
26 | */
27 | export const fillParent = (data: NodeObj, parent?: NodeObj) => {
28 | data.parent = parent
29 | if (data.children) {
30 | for (let i = 0; i < data.children.length; i++) {
31 | fillParent(data.children[i], data)
32 | }
33 | }
34 | }
35 |
36 | export const setExpand = (node: NodeObj, isExpand: boolean, level?: number) => {
37 | node.expanded = isExpand
38 | if (node.children) {
39 | if (level === undefined || level > 0) {
40 | const nextLevel = level !== undefined ? level - 1 : undefined
41 | node.children.forEach(child => {
42 | setExpand(child, isExpand, nextLevel)
43 | })
44 | } else {
45 | node.children.forEach(child => {
46 | setExpand(child, false)
47 | })
48 | }
49 | }
50 | }
51 |
52 | export function refreshIds(data: NodeObj) {
53 | data.id = generateUUID()
54 | if (data.children) {
55 | for (let i = 0; i < data.children.length; i++) {
56 | refreshIds(data.children[i])
57 | }
58 | }
59 | }
60 |
61 | export const throttle = void>(fn: T, wait: number) => {
62 | let pre = Date.now()
63 | return function (...args: Parameters) {
64 | const now = Date.now()
65 | if (now - pre < wait) return
66 | fn(...args)
67 | pre = Date.now()
68 | }
69 | }
70 |
71 | export function getArrowPoints(p3x: number, p3y: number, p4x: number, p4y: number) {
72 | const deltay = p4y - p3y
73 | const deltax = p3x - p4x
74 | let angle = (Math.atan(Math.abs(deltay) / Math.abs(deltax)) / 3.14) * 180
75 | if (isNaN(angle)) return
76 | if (deltax < 0 && deltay > 0) {
77 | angle = 180 - angle
78 | }
79 | if (deltax < 0 && deltay < 0) {
80 | angle = 180 + angle
81 | }
82 | if (deltax > 0 && deltay < 0) {
83 | angle = 360 - angle
84 | }
85 | const arrowLength = 12
86 | const arrowAngle = 30
87 | const a1 = angle + arrowAngle
88 | const a2 = angle - arrowAngle
89 | return {
90 | x1: p4x + Math.cos((Math.PI * a1) / 180) * arrowLength,
91 | y1: p4y - Math.sin((Math.PI * a1) / 180) * arrowLength,
92 | x2: p4x + Math.cos((Math.PI * a2) / 180) * arrowLength,
93 | y2: p4y - Math.sin((Math.PI * a2) / 180) * arrowLength,
94 | }
95 | }
96 |
97 | export function generateUUID(): string {
98 | return (new Date().getTime().toString(16) + Math.random().toString(16).substr(2)).substr(2, 16)
99 | }
100 |
101 | export const generateNewObj = function (this: MindElixirInstance): NodeObjExport {
102 | const id = generateUUID()
103 | return {
104 | topic: this.newTopicName,
105 | id,
106 | }
107 | }
108 |
109 | export function checkMoveValid(from: NodeObj, to: NodeObj) {
110 | let valid = true
111 | while (to.parent) {
112 | if (to.parent === from) {
113 | valid = false
114 | break
115 | }
116 | to = to.parent
117 | }
118 | return valid
119 | }
120 |
121 | export function deepClone(obj: NodeObj) {
122 | const deepCloneObj = JSON.parse(
123 | JSON.stringify(obj, (k, v) => {
124 | if (k === 'parent') return undefined
125 | return v
126 | })
127 | )
128 | return deepCloneObj
129 | }
130 |
131 | export const getOffsetLT = (parent: HTMLElement, child: HTMLElement) => {
132 | let offsetLeft = 0
133 | let offsetTop = 0
134 | while (child && child !== parent) {
135 | offsetLeft += child.offsetLeft
136 | offsetTop += child.offsetTop
137 | child = child.offsetParent as HTMLElement
138 | }
139 | return { offsetLeft, offsetTop }
140 | }
141 |
142 | export const setAttributes = (el: HTMLElement | SVGElement, attrs: { [key: string]: string }) => {
143 | for (const key in attrs) {
144 | el.setAttribute(key, attrs[key])
145 | }
146 | }
147 |
148 | export const isTopic = (target?: HTMLElement): target is Topic => {
149 | return target ? target.tagName === 'ME-TPC' : false
150 | }
151 |
152 | export const unionTopics = (nodes: Topic[]) => {
153 | return nodes
154 | .filter(node => node.nodeObj.parent)
155 | .filter((node, _, nodes) => {
156 | for (let i = 0; i < nodes.length; i++) {
157 | if (node === nodes[i]) continue
158 | const { parent } = node.nodeObj
159 | if (parent === nodes[i].nodeObj) {
160 | return false
161 | }
162 | }
163 | return true
164 | })
165 | }
166 |
167 | export const getTranslate = (styleText: string) => {
168 | // use translate3d for GPU acceleration
169 | const regex = /translate3d\(([^,]+),\s*([^,]+)/
170 | const match = styleText.match(regex)
171 | return match ? { x: parseFloat(match[1]), y: parseFloat(match[2]) } : { x: 0, y: 0 }
172 | }
173 |
174 | export const on = function (
175 | list: {
176 | [K in keyof GlobalEventHandlersEventMap]: {
177 | dom: EventTarget
178 | evt: K
179 | func: (this: EventTarget, ev: GlobalEventHandlersEventMap[K]) => void
180 | }
181 | }[keyof GlobalEventHandlersEventMap][]
182 | ) {
183 | for (let i = 0; i < list.length; i++) {
184 | const { dom, evt, func } = list[i]
185 | dom.addEventListener(evt, func as EventListener)
186 | }
187 | return function off() {
188 | for (let i = 0; i < list.length; i++) {
189 | const { dom, evt, func } = list[i]
190 | dom.removeEventListener(evt, func as EventListener)
191 | }
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/src/dev.ts:
--------------------------------------------------------------------------------
1 | import type { MindElixirCtor } from './index'
2 | import MindElixir from './index'
3 | import example from './exampleData/1'
4 | import example2 from './exampleData/2'
5 | import example3 from './exampleData/3'
6 | import type { Options, MindElixirInstance, NodeObj } from './types/index'
7 | import type { Operation } from './utils/pubsub'
8 | import 'katex/dist/katex.min.css'
9 | import katex from 'katex'
10 | import { layoutSSR, renderSSRHTML } from './utils/layout-ssr'
11 | import { snapdom } from '@zumer/snapdom'
12 | import type { Tokens } from 'marked'
13 | import { marked } from 'marked'
14 | import { md2html } from 'simple-markdown-to-html'
15 | import type { Arrow } from './arrow'
16 | import type { Summary } from './summary'
17 |
18 | interface Window {
19 | m?: MindElixirInstance
20 | m2?: MindElixirInstance
21 | M: MindElixirCtor
22 | E: typeof MindElixir.E
23 | downloadPng: () => void
24 | downloadSvg: () => void
25 | destroy: () => void
26 | testMarkdown: () => void
27 | addMarkdownNode: () => void
28 | }
29 |
30 | declare let window: Window
31 |
32 | const E = MindElixir.E
33 | const options: Options = {
34 | el: '#map',
35 | newTopicName: '子节点',
36 | locale: 'en',
37 | // mouseSelectionButton: 2,
38 | draggable: true,
39 | editable: true,
40 | markdown: (text: string, obj: (NodeObj & { useMd?: boolean }) | (Arrow & { useMd?: boolean }) | (Summary & { useMd?: boolean })) => {
41 | if (!text) return ''
42 | // if (!obj.useMd) return text
43 | try {
44 | // Configure marked renderer to add target="_blank" to links
45 | const renderer = {
46 | strong(token: Tokens.Strong) {
47 | if (token.raw.startsWith('**')) {
48 | return `${token.text}`
49 | } else if (token.raw.startsWith('__')) {
50 | return `${token.text}`
51 | }
52 | return `${token.text}`
53 | },
54 | link(token: Tokens.Link) {
55 | const href = token.href || ''
56 | const title = token.title ? ` title="${token.title}"` : ''
57 | const text = token.text || ''
58 | return `${text}`
59 | },
60 | }
61 |
62 | marked.use({ renderer, gfm: true })
63 | let html = marked.parse(text) as string
64 | // let html = md2html(text)
65 |
66 | // Process KaTeX math expressions
67 | // Handle display math ($$...$$)
68 | html = html.replace(/\$\$([^$]+)\$\$/g, (_, math) => {
69 | return katex.renderToString(math.trim(), { displayMode: true })
70 | })
71 |
72 | // Handle inline math ($...$)
73 | html = html.replace(/\$([^$]+)\$/g, (_, math) => {
74 | return katex.renderToString(math.trim(), { displayMode: false })
75 | })
76 |
77 | return html.trim().replace(/\n/g, '')
78 | } catch (error) {
79 | return text
80 | }
81 | },
82 | // To disable markdown, simply omit the markdown option or set it to undefined
83 | // if you set contextMenu to false, you should handle contextmenu event by yourself, e.g. preventDefault
84 | contextMenu: {
85 | focus: true,
86 | link: true,
87 | extend: [
88 | {
89 | name: 'Node edit',
90 | onclick: () => {
91 | alert('extend menu')
92 | },
93 | },
94 | ],
95 | },
96 | toolBar: true,
97 | keypress: {
98 | e(e) {
99 | if (!mind.currentNode) return
100 | if (e.metaKey || e.ctrlKey) {
101 | mind.expandNode(mind.currentNode)
102 | }
103 | },
104 | f(e) {
105 | if (!mind.currentNode) return
106 | if (e.altKey) {
107 | if (mind.isFocusMode) {
108 | mind.cancelFocus()
109 | } else {
110 | mind.focusNode(mind.currentNode)
111 | }
112 | }
113 | },
114 | },
115 | allowUndo: true,
116 | before: {
117 | insertSibling(el, obj) {
118 | console.log('insertSibling', el, obj)
119 | return true
120 | },
121 | async addChild(el, obj) {
122 | console.log('addChild', el, obj)
123 | // await sleep()
124 | return true
125 | },
126 | },
127 | // scaleMin:0.1
128 | // alignment: 'nodes',
129 | }
130 |
131 | let mind = new MindElixir(options)
132 |
133 | const data = MindElixir.new('new topic')
134 | // example.theme = MindElixir.DARK_THEME
135 | mind.init(example)
136 |
137 | const m2 = new MindElixir({
138 | el: '#map2',
139 | selectionContainer: 'body', // use body to make selection usable when transform is not 0
140 | direction: MindElixir.RIGHT,
141 | theme: MindElixir.DARK_THEME,
142 | // alignment: 'nodes',
143 | })
144 | m2.init(data)
145 |
146 | function sleep() {
147 | return new Promise(res => {
148 | setTimeout(() => res(), 1000)
149 | })
150 | }
151 | // console.log('test E function', E('bd4313fbac40284b'))
152 |
153 | mind.bus.addListener('operation', (operation: Operation) => {
154 | console.log(operation)
155 | // return {
156 | // name: action name,
157 | // obj: target object
158 | // }
159 |
160 | // name: [insertSibling|addChild|removeNode|beginEdit|finishEdit]
161 | // obj: target
162 |
163 | // name: moveNodeIn
164 | // obj: {from:target1,to:target2}
165 | })
166 | mind.bus.addListener('selectNodes', nodes => {
167 | console.log('selectNodes', nodes)
168 | })
169 | mind.bus.addListener('unselectNodes', nodes => {
170 | console.log('unselectNodes', nodes)
171 | })
172 | mind.bus.addListener('changeDirection', direction => {
173 | console.log('changeDirection: ', direction)
174 | })
175 |
176 | const dl2 = async () => {
177 | const result = await snapdom(mind.nodes)
178 | await result.download({ format: 'jpg', filename: 'my-capture' })
179 | }
180 |
181 | window.downloadPng = dl2
182 | window.m = mind
183 | window.m2 = m2
184 | window.M = MindElixir
185 | window.E = MindElixir.E
186 |
187 | console.log('MindElixir Version', MindElixir.version)
188 |
189 | window.destroy = () => {
190 | mind.destroy()
191 | // @ts-expect-error remove reference
192 | mind = null
193 | // @ts-expect-error remove reference
194 | window.m = null
195 | }
196 |
197 | document.querySelector('#ssr')!.innerHTML = renderSSRHTML(layoutSSR(window.m.nodeData))
198 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import './index.less'
2 | import './markdown.css'
3 | import { LEFT, RIGHT, SIDE, DARK_THEME, THEME } from './const'
4 | import { generateUUID } from './utils/index'
5 | import initMouseEvent from './mouse'
6 | import { createBus } from './utils/pubsub'
7 | import { findEle } from './utils/dom'
8 | import { createLinkSvg, createLine } from './utils/svg'
9 | import type { MindElixirData, MindElixirInstance, MindElixirMethods, Options } from './types/index'
10 | import methods from './methods'
11 | import { sub, main } from './utils/generateBranch'
12 | import { version } from '../package.json'
13 | import { createDragMoveHelper } from './utils/dragMoveHelper'
14 | import type { Topic } from './docs'
15 |
16 | // TODO show up animation
17 | const $d = document
18 |
19 | function MindElixir(
20 | this: MindElixirInstance,
21 | {
22 | el,
23 | direction,
24 | locale,
25 | draggable,
26 | editable,
27 | contextMenu,
28 | toolBar,
29 | keypress,
30 | mouseSelectionButton,
31 | selectionContainer,
32 | before,
33 | newTopicName,
34 | allowUndo,
35 | generateMainBranch,
36 | generateSubBranch,
37 | overflowHidden,
38 | theme,
39 | alignment,
40 | scaleSensitivity,
41 | scaleMax,
42 | scaleMin,
43 | handleWheel,
44 | markdown,
45 | imageProxy,
46 | }: Options
47 | ): void {
48 | let ele: HTMLElement | null = null
49 | const elType = Object.prototype.toString.call(el)
50 | if (elType === '[object HTMLDivElement]') {
51 | ele = el as HTMLElement
52 | } else if (elType === '[object String]') {
53 | ele = document.querySelector(el as string) as HTMLElement
54 | }
55 | if (!ele) throw new Error('MindElixir: el is not a valid element')
56 |
57 | ele.style.position = 'relative'
58 | ele.innerHTML = ''
59 | this.el = ele as HTMLElement
60 | this.disposable = []
61 | this.before = before || {}
62 | this.locale = locale || 'en'
63 | this.newTopicName = newTopicName || 'New Node'
64 | this.contextMenu = contextMenu ?? true
65 | this.toolBar = toolBar ?? true
66 | this.keypress = keypress ?? true
67 | this.mouseSelectionButton = mouseSelectionButton ?? 0
68 | this.direction = direction ?? 1
69 | this.draggable = draggable ?? true
70 | this.editable = editable ?? true
71 | this.allowUndo = allowUndo ?? true
72 | this.scaleSensitivity = scaleSensitivity ?? 0.1
73 | this.scaleMax = scaleMax ?? 1.4
74 | this.scaleMin = scaleMin ?? 0.2
75 | this.generateMainBranch = generateMainBranch || main
76 | this.generateSubBranch = generateSubBranch || sub
77 | this.overflowHidden = overflowHidden ?? false
78 | this.alignment = alignment ?? 'root'
79 | this.handleWheel = handleWheel ?? true
80 | this.markdown = markdown || undefined // Custom markdown parser function
81 | this.imageProxy = imageProxy || undefined // Image proxy function
82 | // this.parentMap = {} // deal with large amount of nodes
83 | this.currentNodes = [] // selected elements
84 | this.currentArrow = null // the selected link svg element
85 | this.scaleVal = 1
86 | this.tempDirection = null
87 |
88 | this.dragMoveHelper = createDragMoveHelper(this)
89 | this.bus = createBus()
90 |
91 | this.container = $d.createElement('div') // map container
92 | this.selectionContainer = selectionContainer || this.container
93 |
94 | this.container.className = 'map-container'
95 |
96 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
97 | this.theme = theme || (mediaQuery.matches ? DARK_THEME : THEME)
98 |
99 | // infrastructure
100 | const canvas = $d.createElement('div') // map-canvas Element
101 | canvas.className = 'map-canvas'
102 | this.map = canvas
103 | this.container.setAttribute('tabindex', '0')
104 | this.container.appendChild(this.map)
105 | this.el.appendChild(this.container)
106 |
107 | this.nodes = $d.createElement('me-nodes')
108 |
109 | this.lines = createLinkSvg('lines') // main link container
110 | this.summarySvg = createLinkSvg('summary') // summary container
111 |
112 | this.linkController = createLinkSvg('linkcontroller') // bezier controller container
113 | this.P2 = $d.createElement('div') // bezier P2
114 | this.P3 = $d.createElement('div') // bezier P3
115 | this.P2.className = this.P3.className = 'circle'
116 | this.P2.style.display = this.P3.style.display = 'none'
117 | this.line1 = createLine() // bezier auxiliary line1
118 | this.line2 = createLine() // bezier auxiliary line2
119 | this.linkController.appendChild(this.line1)
120 | this.linkController.appendChild(this.line2)
121 | this.linkSvgGroup = createLinkSvg('topiclinks') // storage user custom link svg
122 |
123 | this.labelContainer = $d.createElement('div') // container for SVG labels
124 | this.labelContainer.className = 'label-container'
125 |
126 | this.map.appendChild(this.nodes)
127 |
128 | if (this.overflowHidden) {
129 | this.container.style.overflow = 'hidden'
130 | } else {
131 | this.disposable.push(initMouseEvent(this))
132 | }
133 | }
134 |
135 | MindElixir.prototype = methods
136 |
137 | Object.defineProperty(MindElixir.prototype, 'currentNode', {
138 | get() {
139 | return this.currentNodes[this.currentNodes.length - 1]
140 | },
141 | enumerable: true,
142 | })
143 |
144 | MindElixir.LEFT = LEFT
145 | MindElixir.RIGHT = RIGHT
146 | MindElixir.SIDE = SIDE
147 |
148 | MindElixir.THEME = THEME
149 | MindElixir.DARK_THEME = DARK_THEME
150 |
151 | /**
152 | * @memberof MindElixir
153 | * @static
154 | */
155 | MindElixir.version = version
156 | /**
157 | * @function
158 | * @memberof MindElixir
159 | * @static
160 | * @name E
161 | * @param {string} id Node id.
162 | * @return {TargetElement} Target element.
163 | * @example
164 | * E('bd4313fbac40284b')
165 | */
166 | MindElixir.E = findEle
167 |
168 | /**
169 | * @function new
170 | * @memberof MindElixir
171 | * @static
172 | * @param {String} topic root topic
173 | */
174 | if (import.meta.env.MODE !== 'lite') {
175 | MindElixir.new = (topic: string): MindElixirData => ({
176 | nodeData: {
177 | id: generateUUID(),
178 | topic: topic || 'new topic',
179 | children: [],
180 | },
181 | })
182 | }
183 |
184 | export interface MindElixirCtor {
185 | new (options: Options): MindElixirInstance
186 | E: (id: string, el?: HTMLElement) => Topic
187 | new: typeof MindElixir.new
188 | version: string
189 | LEFT: typeof LEFT
190 | RIGHT: typeof RIGHT
191 | SIDE: typeof SIDE
192 | THEME: typeof THEME
193 | DARK_THEME: typeof DARK_THEME
194 | prototype: MindElixirMethods
195 | }
196 |
197 | export default MindElixir as unknown as MindElixirCtor
198 |
199 | // types
200 | export type * from './utils/pubsub'
201 | export type * from './types/index'
202 | export type * from './types/dom'
203 |
--------------------------------------------------------------------------------
/tests/expand-collapse.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from './mind-elixir-test'
2 |
3 | const data = {
4 | nodeData: {
5 | topic: 'root',
6 | id: 'root',
7 | children: [
8 | {
9 | id: 'branch1',
10 | topic: 'Branch 1',
11 | expanded: true,
12 | children: [
13 | {
14 | id: 'child1',
15 | topic: 'Child 1',
16 | },
17 | {
18 | id: 'child2',
19 | topic: 'Child 2',
20 | children: [
21 | {
22 | id: 'grandchild1',
23 | topic: 'Grandchild 1',
24 | },
25 | {
26 | id: 'grandchild2',
27 | topic: 'Grandchild 2',
28 | },
29 | ],
30 | },
31 | ],
32 | },
33 | {
34 | id: 'branch2',
35 | topic: 'Branch 2',
36 | expanded: false, // Initially collapsed
37 | children: [
38 | {
39 | id: 'child3',
40 | topic: 'Child 3',
41 | },
42 | {
43 | id: 'child4',
44 | topic: 'Child 4',
45 | },
46 | ],
47 | },
48 | {
49 | id: 'branch3',
50 | topic: 'Branch 3',
51 | children: [
52 | {
53 | id: 'child5',
54 | topic: 'Child 5',
55 | },
56 | ],
57 | },
58 | ],
59 | },
60 | }
61 |
62 | test.beforeEach(async ({ me }) => {
63 | await me.init(data)
64 | })
65 |
66 | test('Expand collapsed node', async ({ page, me }) => {
67 | // Verify initial state: Branch 2 is collapsed
68 | const branch2 = page.getByText('Branch 2', { exact: true })
69 | await expect(branch2).toBeVisible()
70 |
71 | // Child nodes should not be visible
72 | await expect(page.getByText('Child 3', { exact: true })).not.toBeVisible()
73 | await expect(page.getByText('Child 4', { exact: true })).not.toBeVisible()
74 |
75 | // Click expand button
76 | const expandButton = page.locator('me-tpc[data-nodeid="mebranch2"]').locator('..').locator('me-epd')
77 | await expandButton.click()
78 |
79 | // Verify child nodes are now visible
80 | await expect(page.getByText('Child 3', { exact: true })).toBeVisible()
81 | await expect(page.getByText('Child 4', { exact: true })).toBeVisible()
82 |
83 |
84 | })
85 |
86 | test('Collapse expanded node', async ({ page, me }) => {
87 | // Branch 1 is initially expanded
88 | await expect(page.getByText('Child 1', { exact: true })).toBeVisible()
89 | await expect(page.getByText('Child 2', { exact: true })).toBeVisible()
90 |
91 | // Click collapse button
92 | const collapseButton = page.locator('me-tpc[data-nodeid="mebranch1"]').locator('..').locator('me-epd')
93 | await collapseButton.click()
94 |
95 | // Verify child nodes are now not visible
96 | await expect(page.getByText('Child 1', { exact: true })).not.toBeVisible()
97 | await expect(page.getByText('Child 2', { exact: true })).not.toBeVisible()
98 |
99 |
100 | })
101 |
102 | test('Expand all children recursively', async ({ page, me }) => {
103 | // First collapse Branch 1
104 | const branch1Button = page.locator('me-tpc[data-nodeid="mebranch1"]').locator('..').locator('me-epd')
105 | await branch1Button.click()
106 |
107 | // Verify all child nodes are not visible
108 | await expect(page.getByText('Child 1', { exact: true })).not.toBeVisible()
109 | await expect(page.getByText('Child 2', { exact: true })).not.toBeVisible()
110 | await expect(page.getByText('Grandchild 1', { exact: true })).not.toBeVisible()
111 |
112 | // Ctrl click for recursive expansion
113 | await page.keyboard.down("Control");
114 | await branch1Button.click()
115 | await page.keyboard.up("Control");
116 |
117 | // Verify all levels of child nodes are visible
118 | await expect(page.getByText('Child 1', { exact: true })).toBeVisible()
119 | await expect(page.getByText('Child 2', { exact: true })).toBeVisible()
120 | await expect(page.getByText('Grandchild 1', { exact: true })).toBeVisible()
121 | await expect(page.getByText('Grandchild 2', { exact: true })).toBeVisible()
122 |
123 |
124 | })
125 |
126 | test('Auto expand when moving node to collapsed parent', async ({ page, me }) => {
127 | // First ensure Branch 2 is collapsed
128 | const branch2 = page.getByText('Branch 2', { exact: true })
129 | await expect(page.getByText('Child 3', { exact: true })).not.toBeVisible()
130 |
131 | // Select Child 5 for moving
132 | const child5 = page.getByText('Child 5', { exact: true })
133 | await child5.hover({ force: true })
134 | await page.mouse.down()
135 |
136 | // Drag to collapsed Branch 2
137 | await me.dragOver('Branch 2', 'in')
138 | await expect(page.locator('.insert-preview.in')).toBeVisible()
139 |
140 | // Release mouse to complete move
141 | await page.mouse.up()
142 |
143 | // Verify Branch 2 auto-expands and Child 5 is now in it
144 | await expect(page.getByText('Child 3', { exact: true })).toBeVisible()
145 | await expect(page.getByText('Child 4', { exact: true })).toBeVisible()
146 | await expect(page.getByText('Child 5', { exact: true })).toBeVisible()
147 |
148 | // Verify Child 5 actually moved under Branch 2
149 | const branch2Container = page.locator('me-tpc[data-nodeid="mebranch2"]').locator('..').locator('..').locator('me-children')
150 | await expect(branch2Container.getByText('Child 5', { exact: true })).toBeVisible()
151 | })
152 |
153 | test('Auto expand when copying node to collapsed parent', async ({ page, me }) => {
154 | // Ensure Branch 2 is collapsed
155 | await expect(page.getByText('Child 3', { exact: true })).not.toBeVisible()
156 |
157 | // Select Child 1 and copy
158 | await me.click('Child 1')
159 | await page.keyboard.press('Control+c')
160 |
161 | // Select collapsed Branch 2
162 | await me.click('Branch 2')
163 |
164 | // Paste
165 | await page.keyboard.press('Control+v')
166 |
167 | // Verify Branch 2 auto-expands and contains copied node
168 | await expect(page.getByText('Child 3', { exact: true })).toBeVisible()
169 | await expect(page.getByText('Child 4', { exact: true })).toBeVisible()
170 |
171 | // Should have two "Child 1" (original and copied)
172 | const child1Elements = page.getByText('Child 1', { exact: true })
173 | await expect(child1Elements).toHaveCount(2)
174 |
175 |
176 | })
177 |
178 | test('Expand state persistence after layout refresh', async ({ page, me }) => {
179 | // Expand Branch 2
180 | const expandButton = page.locator('me-tpc[data-nodeid="mebranch2"]').locator('..').locator('me-epd')
181 | await expandButton.click()
182 | await expect(page.getByText('Child 3', { exact: true })).toBeVisible()
183 |
184 | // Get current data and reinitialize
185 | const currentData = await me.getData()
186 | await me.init(currentData)
187 |
188 | // Verify expand state persists
189 | await expect(page.getByText('Child 3', { exact: true })).toBeVisible()
190 | await expect(page.getByText('Child 4', { exact: true })).toBeVisible()
191 | })
192 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import type { Topic, CustomSvg } from './dom'
2 | import type { createBus, EventMap, Operation } from '../utils/pubsub'
3 | import type { MindElixirMethods, OperationMap, Operations } from '../methods'
4 | import type { LinkDragMoveHelperInstance } from '../utils/LinkDragMoveHelper'
5 | import type { Arrow } from '../arrow'
6 | import type { Summary, SummarySvgGroup } from '../summary'
7 | import type { MainLineParams, SubLineParams } from '../utils/generateBranch'
8 | import type { Locale } from '../i18n'
9 | import type { ContextMenuOption } from '../plugin/contextMenu'
10 | import type { createDragMoveHelper } from '../utils/dragMoveHelper'
11 | import type SelectionArea from '../viselect/src'
12 | export { type MindElixirMethods } from '../methods'
13 |
14 | export const DirectionClass = {
15 | LHS: 'lhs',
16 | RHS: 'rhs',
17 | } as const
18 |
19 | export type DirectionClass = (typeof DirectionClass)[keyof typeof DirectionClass]
20 |
21 | type Before = Partial<{
22 | [K in Operations]: (...args: Parameters) => Promise | boolean
23 | }>
24 |
25 | /**
26 | * MindElixir Theme
27 | *
28 | * @public
29 | */
30 | export type Theme = {
31 | name: string
32 | /**
33 | * Hint for developers to use the correct theme
34 | */
35 | type?: 'light' | 'dark'
36 | /**
37 | * Color palette for main branches
38 | */
39 | palette: string[]
40 | cssVar: {
41 | '--node-gap-x': string
42 | '--node-gap-y': string
43 | '--main-gap-x': string
44 | '--main-gap-y': string
45 | '--main-color': string
46 | '--main-bgcolor': string
47 | '--color': string
48 | '--bgcolor': string
49 | '--selected': string
50 | '--accent-color': string
51 | '--root-color': string
52 | '--root-bgcolor': string
53 | '--root-border-color': string
54 | '--root-radius': string
55 | '--main-radius': string
56 | '--topic-padding': string
57 | '--panel-color': string
58 | '--panel-bgcolor': string
59 | '--panel-border-color': string
60 | '--map-padding': string
61 | }
62 | }
63 |
64 | export type Alignment = 'root' | 'nodes'
65 |
66 | export interface KeypressOptions {
67 | [key: string]: (e: KeyboardEvent) => void
68 | }
69 |
70 | /**
71 | * The MindElixir instance
72 | *
73 | * @public
74 | */
75 | export interface MindElixirInstance extends Omit, 'markdown' | 'imageProxy'>, MindElixirMethods {
76 | markdown?: (markdown: string, obj: NodeObj | Arrow | Summary) => string // Keep markdown as optional
77 | imageProxy?: (url: string) => string // Keep imageProxy as optional
78 | dragged: Topic[] | null // currently dragged nodes
79 | spacePressed: boolean // space key pressed state
80 | el: HTMLElement
81 | disposable: Array<() => void>
82 | isFocusMode: boolean
83 | nodeDataBackup: NodeObj
84 |
85 | nodeData: NodeObj
86 | arrows: Arrow[]
87 | summaries: Summary[]
88 |
89 | readonly currentNode: Topic | null
90 | currentNodes: Topic[]
91 | currentSummary: SummarySvgGroup | null
92 | currentArrow: CustomSvg | null
93 | waitCopy: Topic[] | null
94 |
95 | scaleVal: number
96 | tempDirection: 0 | 1 | 2 | null
97 |
98 | container: HTMLElement
99 | map: HTMLElement
100 | root: HTMLElement
101 | nodes: HTMLElement
102 | lines: SVGElement
103 | summarySvg: SVGElement
104 | linkController: SVGElement
105 | labelContainer: HTMLElement // Container for SVG labels
106 | P2: HTMLElement
107 | P3: HTMLElement
108 | line1: SVGElement
109 | line2: SVGElement
110 | linkSvgGroup: SVGElement
111 | /**
112 | * @internal
113 | */
114 | helper1?: LinkDragMoveHelperInstance
115 | /**
116 | * @internal
117 | */
118 | helper2?: LinkDragMoveHelperInstance
119 |
120 | bus: ReturnType>
121 | history: Operation[]
122 | undo: () => void
123 | redo: () => void
124 |
125 | selection: SelectionArea
126 | dragMoveHelper: ReturnType
127 | }
128 | type PathString = string
129 | /**
130 | * The MindElixir options
131 | *
132 | * @public
133 | */
134 | export interface Options {
135 | el: string | HTMLElement
136 | direction?: 0 | 1 | 2
137 | locale?: Locale
138 | draggable?: boolean
139 | editable?: boolean
140 | contextMenu?: boolean | ContextMenuOption
141 | toolBar?: boolean
142 | keypress?: boolean | KeypressOptions
143 | mouseSelectionButton?: 0 | 2
144 | before?: Before
145 | newTopicName?: string
146 | allowUndo?: boolean
147 | overflowHidden?: boolean
148 | generateMainBranch?: (this: MindElixirInstance, params: MainLineParams) => PathString
149 | generateSubBranch?: (this: MindElixirInstance, params: SubLineParams) => PathString
150 | theme?: Theme
151 | selectionContainer?: string | HTMLElement
152 | alignment?: Alignment
153 | scaleSensitivity?: number
154 | scaleMin?: number
155 | scaleMax?: number
156 | handleWheel?: true | ((e: WheelEvent) => void)
157 | /**
158 | * Custom markdown parser function that takes markdown string and returns HTML string
159 | * If not provided, markdown will be disabled
160 | * @default undefined
161 | */
162 | markdown?: (markdown: string, obj: NodeObj | Arrow | Summary) => string
163 | /**
164 | * Image proxy function to handle image URLs, mainly used to solve CORS issues
165 | * If provided, all image URLs will be processed through this function before setting to img src
166 | * @default undefined
167 | */
168 | imageProxy?: (url: string) => string
169 | }
170 |
171 | export type Uid = string
172 |
173 | export type Left = 0
174 | export type Right = 1
175 |
176 | /**
177 | * Tag object for node tags with optional styling
178 | *
179 | * @public
180 | */
181 | export interface TagObj {
182 | text: string
183 | style?: Partial | Record
184 | className?: string
185 | }
186 |
187 | /**
188 | * MindElixir node object
189 | *
190 | * @public
191 | */
192 | export interface NodeObj {
193 | topic: string
194 | id: Uid
195 | style?: Partial<{
196 | fontSize: string
197 | fontFamily: string
198 | color: string
199 | background: string
200 | fontWeight: string
201 | width: string
202 | border: string
203 | textDecoration: string
204 | }>
205 | children?: NodeObj[]
206 | tags?: (string | TagObj)[]
207 | icons?: string[]
208 | hyperLink?: string
209 | expanded?: boolean
210 | direction?: Left | Right
211 | image?: {
212 | url: string
213 | width: number
214 | height: number
215 | fit?: 'fill' | 'contain' | 'cover'
216 | }
217 | /**
218 | * The color of the branch.
219 | */
220 | branchColor?: string
221 | /**
222 | * This property is added programatically, do not set it manually.
223 | *
224 | * the Root node has no parent!
225 | */
226 | parent?: NodeObj
227 | /**
228 | * Render custom HTML in the node.
229 | *
230 | * Everything in the node will be replaced by this property.
231 | */
232 | dangerouslySetInnerHTML?: string
233 | /**
234 | * Extra data for the node, which can be used to store any custom data.
235 | */
236 | note?: string
237 | // TODO: checkbox
238 | // checkbox?: boolean | undefined
239 | }
240 | export type NodeObjExport = Omit
241 |
242 | /**
243 | * The exported data of MindElixir
244 | *
245 | * @public
246 | */
247 | export type MindElixirData = {
248 | nodeData: NodeObj
249 | arrows?: Arrow[]
250 | summaries?: Summary[]
251 | direction?: 0 | 1 | 2
252 | theme?: Theme
253 | }
254 |
--------------------------------------------------------------------------------
/src/utils/svg.ts:
--------------------------------------------------------------------------------
1 | import { setAttributes } from '.'
2 | import type { Arrow } from '../arrow'
3 | import type { Summary } from '../summary'
4 | import type { MindElixirInstance } from '../types'
5 | import type { CustomSvg } from '../types/dom'
6 | import { selectText } from './dom'
7 |
8 | const $d = document
9 | export const svgNS = 'http://www.w3.org/2000/svg'
10 |
11 | export interface SvgTextOptions {
12 | anchor?: 'start' | 'middle' | 'end'
13 | color?: string
14 | dataType: string
15 | svgId: string // Associated SVG element ID
16 | }
17 |
18 | /**
19 | * Create a div label for SVG elements with positioning
20 | */
21 | // Helper function to calculate precise position based on actual DOM dimensions
22 | export const calculatePrecisePosition = function (element: HTMLElement): void {
23 | // Get actual dimensions
24 | const actualWidth = element.clientWidth
25 | const actualHeight = element.clientHeight
26 | const data = element.dataset
27 | const x = Number(data.x)
28 | const y = Number(data.y)
29 | const anchor = data.anchor
30 |
31 | // Calculate position based on anchor and actual dimensions
32 | let adjustedX = x
33 | if (anchor === 'middle') {
34 | adjustedX = x - actualWidth / 2
35 | } else if (anchor === 'end') {
36 | adjustedX = x - actualWidth
37 | }
38 |
39 | // Set final position with actual dimensions
40 | element.style.left = `${adjustedX}px`
41 | element.style.top = `${y - actualHeight / 2}px`
42 | element.style.visibility = 'visible'
43 | }
44 |
45 | export const createLabel = function (text: string, x: number, y: number, options: SvgTextOptions): HTMLDivElement {
46 | const { anchor = 'middle', color, dataType, svgId } = options
47 |
48 | // Create label div element
49 | const labelDiv = document.createElement('div')
50 | labelDiv.className = 'svg-label'
51 | labelDiv.style.color = color || '#666'
52 |
53 | // Generate unique ID for the label
54 | const labelId = 'label-' + svgId
55 | labelDiv.id = labelId
56 | labelDiv.innerHTML = text
57 |
58 | labelDiv.dataset.type = dataType
59 | labelDiv.dataset.svgId = svgId
60 | labelDiv.dataset.x = x.toString()
61 | labelDiv.dataset.y = y.toString()
62 | labelDiv.dataset.anchor = anchor
63 |
64 | return labelDiv
65 | }
66 |
67 | /**
68 | * Find SVG element by label ID
69 | */
70 | export const findSvgByLabelId = function (labelId: string): SVGElement | null {
71 | const labelEl = document.getElementById(labelId) as HTMLElement
72 | if (!labelEl || !labelEl.dataset.svgId) {
73 | return null
74 | }
75 | const svgElement = document.getElementById(labelEl.dataset.svgId)
76 | return svgElement as unknown as SVGElement
77 | }
78 |
79 | /**
80 | * Find label element by SVG ID
81 | */
82 | export const findLabelBySvgId = function (svgId: string): HTMLDivElement | null {
83 | const labelEl = document.querySelector(`[data-svg-id="${svgId}"]`) as HTMLDivElement
84 | return labelEl
85 | }
86 |
87 | export const createPath = function (d: string, color: string, width: string) {
88 | const path = $d.createElementNS(svgNS, 'path')
89 | setAttributes(path, {
90 | d,
91 | stroke: color || '#666',
92 | fill: 'none',
93 | 'stroke-width': width,
94 | })
95 | return path
96 | }
97 |
98 | export const createLinkSvg = function (klass: string) {
99 | const svg = $d.createElementNS(svgNS, 'svg')
100 | svg.setAttribute('class', klass)
101 | svg.setAttribute('overflow', 'visible')
102 | return svg
103 | }
104 |
105 | export const createLine = function () {
106 | const line = $d.createElementNS(svgNS, 'line')
107 | line.setAttribute('stroke', '#4dc4ff')
108 | line.setAttribute('fill', 'none')
109 | line.setAttribute('stroke-width', '2')
110 | line.setAttribute('opacity', '0.45')
111 | return line
112 | }
113 |
114 | export const createArrowGroup = function (
115 | d: string,
116 | arrowd1: string,
117 | arrowd2: string,
118 | style?: {
119 | stroke?: string
120 | strokeWidth?: string | number
121 | strokeDasharray?: string
122 | strokeLinecap?: 'butt' | 'round' | 'square'
123 | opacity?: string | number
124 | }
125 | ): CustomSvg {
126 | const g = $d.createElementNS(svgNS, 'g') as CustomSvg
127 | const svgs = [
128 | {
129 | name: 'line',
130 | d,
131 | },
132 | {
133 | name: 'arrow1',
134 | d: arrowd1,
135 | },
136 | {
137 | name: 'arrow2',
138 | d: arrowd2,
139 | },
140 | ] as const
141 | svgs.forEach((item, i) => {
142 | const d = item.d
143 | const path = $d.createElementNS(svgNS, 'path')
144 | const attrs: { [key: string]: string } = {
145 | d,
146 | stroke: style?.stroke || 'rgb(227, 125, 116)',
147 | fill: 'none',
148 | 'stroke-linecap': style?.strokeLinecap || 'cap',
149 | 'stroke-width': String(style?.strokeWidth || '2'),
150 | }
151 |
152 | if (style?.opacity !== undefined) {
153 | attrs['opacity'] = String(style.opacity)
154 | }
155 |
156 | setAttributes(path, attrs)
157 |
158 | if (i === 0) {
159 | // Apply stroke-dasharray to the main line
160 | path.setAttribute('stroke-dasharray', style?.strokeDasharray || '8,2')
161 | }
162 |
163 | const hotzone = $d.createElementNS(svgNS, 'path')
164 | const hotzoneAttrs = {
165 | d,
166 | stroke: 'transparent',
167 | fill: 'none',
168 | 'stroke-width': '15',
169 | }
170 | setAttributes(hotzone, hotzoneAttrs)
171 | g.appendChild(hotzone)
172 |
173 | g.appendChild(path)
174 | g[item.name] = path
175 | })
176 | return g
177 | }
178 |
179 | export const editSvgText = function (mei: MindElixirInstance, textEl: HTMLDivElement, node: Summary | Arrow) {
180 | if (!textEl) return
181 |
182 | // textEl is now a div element directly
183 | const origin = node.label
184 |
185 | const div = textEl.cloneNode(true) as HTMLDivElement
186 | mei.nodes.appendChild(div)
187 | div.id = 'input-box'
188 | div.textContent = origin
189 | div.contentEditable = 'plaintext-only'
190 | div.spellcheck = false
191 |
192 | div.style.cssText = `
193 | left:${textEl.style.left};
194 | top:${textEl.style.top};
195 | max-width: 200px;
196 | `
197 | selectText(div)
198 | mei.scrollIntoView(div)
199 |
200 | div.addEventListener('keydown', e => {
201 | e.stopPropagation()
202 | const key = e.key
203 |
204 | if (key === 'Enter' || key === 'Tab') {
205 | // keep wrap for shift enter
206 | if (e.shiftKey) return
207 |
208 | e.preventDefault()
209 | div.blur()
210 | mei.container.focus()
211 | }
212 | })
213 |
214 | div.addEventListener('blur', () => {
215 | if (!div) return
216 | const text = div.textContent?.trim() || ''
217 | if (text === '') node.label = origin
218 | else node.label = text
219 | div.remove()
220 | if (text === origin) return
221 |
222 | if (mei.markdown) {
223 | ;(textEl as HTMLDivElement).innerHTML = mei.markdown(node.label, node as any)
224 | } else {
225 | textEl.textContent = node.label
226 | }
227 | // Recalculate position with new content while preserving existing color
228 | calculatePrecisePosition(textEl)
229 |
230 | if ('parent' in node) {
231 | mei.bus.fire('operation', {
232 | name: 'finishEditSummary',
233 | obj: node,
234 | })
235 | } else {
236 | mei.bus.fire('operation', {
237 | name: 'finishEditArrowLabel',
238 | obj: node,
239 | })
240 | }
241 | })
242 | }
243 |
--------------------------------------------------------------------------------
/src/plugin/contextMenu.ts:
--------------------------------------------------------------------------------
1 | import i18n from '../i18n'
2 | import type { Topic } from '../types/dom'
3 | import type { MindElixirInstance } from '../types/index'
4 | import { encodeHTML, isTopic } from '../utils/index'
5 | import './contextMenu.less'
6 | import type { ArrowOptions } from '../arrow'
7 |
8 | export type ContextMenuOption = {
9 | focus?: boolean
10 | link?: boolean
11 | extend?: {
12 | name: string
13 | key?: string
14 | onclick: (e: MouseEvent) => void
15 | }[]
16 | }
17 |
18 | export default function (mind: MindElixirInstance, option: true | ContextMenuOption) {
19 | option =
20 | option === true
21 | ? {
22 | focus: true,
23 | link: true,
24 | }
25 | : option
26 | const createTips = (words: string) => {
27 | const div = document.createElement('div')
28 | div.innerText = words
29 | div.className = 'tips'
30 | return div
31 | }
32 | const createLi = (id: string, name: string, keyname: string) => {
33 | const li = document.createElement('li')
34 | li.id = id
35 | li.innerHTML = `${encodeHTML(name)}${encodeHTML(keyname)}`
36 | return li
37 | }
38 | const locale = i18n[mind.locale] ? mind.locale : 'en'
39 | const lang = i18n[locale]
40 | const add_child = createLi('cm-add_child', lang.addChild, 'Tab')
41 | const add_parent = createLi('cm-add_parent', lang.addParent, 'Ctrl + Enter')
42 | const add_sibling = createLi('cm-add_sibling', lang.addSibling, 'Enter')
43 | const remove_child = createLi('cm-remove_child', lang.removeNode, 'Delete')
44 | const focus = createLi('cm-fucus', lang.focus, '')
45 | const unfocus = createLi('cm-unfucus', lang.cancelFocus, '')
46 | const up = createLi('cm-up', lang.moveUp, 'PgUp')
47 | const down = createLi('cm-down', lang.moveDown, 'Pgdn')
48 | const link = createLi('cm-link', lang.link, '')
49 | const linkBidirectional = createLi('cm-link-bidirectional', lang.linkBidirectional, '')
50 | const summary = createLi('cm-summary', lang.summary, '')
51 |
52 | const menuUl = document.createElement('ul')
53 | menuUl.className = 'menu-list'
54 | menuUl.appendChild(add_child)
55 | menuUl.appendChild(add_parent)
56 | menuUl.appendChild(add_sibling)
57 | menuUl.appendChild(remove_child)
58 | if (option.focus) {
59 | menuUl.appendChild(focus)
60 | menuUl.appendChild(unfocus)
61 | }
62 | menuUl.appendChild(up)
63 | menuUl.appendChild(down)
64 | menuUl.appendChild(summary)
65 | if (option.link) {
66 | menuUl.appendChild(link)
67 | menuUl.appendChild(linkBidirectional)
68 | }
69 | if (option && option.extend) {
70 | for (let i = 0; i < option.extend.length; i++) {
71 | const item = option.extend[i]
72 | const dom = createLi(item.name, item.name, item.key || '')
73 | menuUl.appendChild(dom)
74 | dom.onclick = e => {
75 | item.onclick(e)
76 | }
77 | }
78 | }
79 | const menuContainer = document.createElement('div')
80 | menuContainer.className = 'context-menu'
81 | menuContainer.appendChild(menuUl)
82 | menuContainer.hidden = true
83 |
84 | mind.container.append(menuContainer)
85 | let isRoot = true
86 | // Helper function to actually render and position context menu.
87 | const showMenu = (e: MouseEvent) => {
88 | console.log('showContextMenu', e)
89 | const target = e.target as HTMLElement
90 | if (isTopic(target)) {
91 | if (target.parentElement!.tagName === 'ME-ROOT') {
92 | isRoot = true
93 | } else {
94 | isRoot = false
95 | }
96 | if (isRoot) {
97 | focus.className = 'disabled'
98 | up.className = 'disabled'
99 | down.className = 'disabled'
100 | add_parent.className = 'disabled'
101 | add_sibling.className = 'disabled'
102 | remove_child.className = 'disabled'
103 | } else {
104 | focus.className = ''
105 | up.className = ''
106 | down.className = ''
107 | add_parent.className = ''
108 | add_sibling.className = ''
109 | remove_child.className = ''
110 | }
111 | menuContainer.hidden = false
112 |
113 | menuUl.style.top = ''
114 | menuUl.style.bottom = ''
115 | menuUl.style.left = ''
116 | menuUl.style.right = ''
117 | const rect = menuUl.getBoundingClientRect()
118 | const height = menuUl.offsetHeight
119 | const width = menuUl.offsetWidth
120 |
121 | const relativeY = e.clientY - rect.top
122 | const relativeX = e.clientX - rect.left
123 |
124 | if (height + relativeY > window.innerHeight) {
125 | menuUl.style.top = ''
126 | menuUl.style.bottom = '0px'
127 | } else {
128 | menuUl.style.bottom = ''
129 | menuUl.style.top = relativeY + 15 + 'px'
130 | }
131 |
132 | if (width + relativeX > window.innerWidth) {
133 | menuUl.style.left = ''
134 | menuUl.style.right = '0px'
135 | } else {
136 | menuUl.style.right = ''
137 | menuUl.style.left = relativeX + 10 + 'px'
138 | }
139 | }
140 | }
141 |
142 | mind.bus.addListener('showContextMenu', showMenu)
143 |
144 | menuContainer.onclick = e => {
145 | if (e.target === menuContainer) menuContainer.hidden = true
146 | }
147 |
148 | add_child.onclick = () => {
149 | mind.addChild()
150 | menuContainer.hidden = true
151 | }
152 | add_parent.onclick = () => {
153 | mind.insertParent()
154 | menuContainer.hidden = true
155 | }
156 | add_sibling.onclick = () => {
157 | if (isRoot) return
158 | mind.insertSibling('after')
159 | menuContainer.hidden = true
160 | }
161 | remove_child.onclick = () => {
162 | if (isRoot) return
163 | mind.removeNodes(mind.currentNodes || [])
164 | menuContainer.hidden = true
165 | }
166 | focus.onclick = () => {
167 | if (isRoot) return
168 | mind.focusNode(mind.currentNode as Topic)
169 | menuContainer.hidden = true
170 | }
171 | unfocus.onclick = () => {
172 | mind.cancelFocus()
173 | menuContainer.hidden = true
174 | }
175 | up.onclick = () => {
176 | if (isRoot) return
177 | mind.moveUpNode()
178 | menuContainer.hidden = true
179 | }
180 | down.onclick = () => {
181 | if (isRoot) return
182 | mind.moveDownNode()
183 | menuContainer.hidden = true
184 | }
185 | const linkFunc = (options?: ArrowOptions) => {
186 | menuContainer.hidden = true
187 | const from = mind.currentNode as Topic
188 | const tips = createTips(lang.clickTips)
189 | mind.container.appendChild(tips)
190 | mind.map.addEventListener(
191 | 'click',
192 | e => {
193 | e.preventDefault()
194 | tips.remove()
195 | const target = e.target as Topic
196 | if (target.parentElement.tagName === 'ME-PARENT' || target.parentElement.tagName === 'ME-ROOT') {
197 | mind.createArrow(from, target, options)
198 | } else {
199 | console.log('link cancel')
200 | }
201 | },
202 | {
203 | once: true,
204 | }
205 | )
206 | }
207 | link.onclick = () => linkFunc()
208 | linkBidirectional.onclick = () => linkFunc({ bidirectional: true })
209 | summary.onclick = () => {
210 | menuContainer.hidden = true
211 | mind.createSummary()
212 | mind.unselectNodes(mind.currentNodes)
213 | }
214 | return () => {
215 | // maybe useful?
216 | add_child.onclick = null
217 | add_parent.onclick = null
218 | add_sibling.onclick = null
219 | remove_child.onclick = null
220 | focus.onclick = null
221 | unfocus.onclick = null
222 | up.onclick = null
223 | down.onclick = null
224 | link.onclick = null
225 | summary.onclick = null
226 | menuContainer.onclick = null
227 | mind.container.oncontextmenu = null
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/tests/undo-redo.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from './mind-elixir-test'
2 |
3 | const id = 'root-id'
4 | const topic = 'root-topic'
5 | const childTopic = 'child-topic'
6 | const middleTopic = 'middle'
7 |
8 | const data = {
9 | nodeData: {
10 | topic,
11 | id,
12 | children: [
13 | {
14 | id: 'middle',
15 | topic: middleTopic,
16 | children: [
17 | {
18 | id: 'child',
19 | topic: childTopic,
20 | },
21 | ],
22 | },
23 | ],
24 | },
25 | }
26 |
27 | test.beforeEach(async ({ me }) => {
28 | await me.init(data)
29 | })
30 |
31 | test('Undo/Redo - Add Node Operations', async ({ page, me }) => {
32 | // Select child node and add a sibling
33 | await me.click(childTopic)
34 | await page.keyboard.press('Enter')
35 | await page.keyboard.press('Enter')
36 | await expect(me.getByText('New Node')).toBeVisible()
37 |
38 | // Undo the add operation using Ctrl+Z
39 | await page.keyboard.press('Control+z')
40 | await expect(me.getByText('New Node')).toBeHidden()
41 |
42 | // Redo the add operation using Ctrl+Y
43 | await page.keyboard.press('Control+y')
44 | await expect(me.getByText('New Node')).toBeVisible()
45 |
46 | // Undo again using Ctrl+Z
47 | await page.keyboard.press('Control+z')
48 | await expect(me.getByText('New Node')).toBeHidden()
49 |
50 | // Redo using Ctrl+Shift+Z (alternative redo shortcut)
51 | await page.keyboard.press('Control+Shift+Z')
52 | await expect(me.getByText('New Node')).toBeVisible()
53 | })
54 |
55 | test('Undo/Redo - Remove Node Operations', async ({ page, me }) => {
56 | // Remove child node
57 | await me.click(childTopic)
58 | await page.keyboard.press('Delete')
59 | await expect(me.getByText(childTopic)).toBeHidden()
60 |
61 | // Undo the remove operation
62 | await page.keyboard.press('Control+z')
63 | await expect(me.getByText(childTopic)).toBeVisible()
64 |
65 | // Redo the remove operation
66 | await page.keyboard.press('Control+y')
67 | await expect(me.getByText(childTopic)).toBeHidden()
68 |
69 | // Undo again to restore the node
70 | await page.keyboard.press('Control+z')
71 | await expect(me.getByText(childTopic)).toBeVisible()
72 | })
73 |
74 | test('Undo/Redo - Edit Node Operations', async ({ page, me }) => {
75 | const originalText = childTopic
76 | const newText = 'updated-child-topic'
77 |
78 | // Edit the child node
79 | await me.dblclick(childTopic)
80 | await expect(page.locator('#input-box')).toBeVisible()
81 | await page.keyboard.insertText(newText)
82 | await page.keyboard.press('Enter')
83 | await expect(me.getByText(newText)).toBeVisible()
84 |
85 | // Undo the edit operation
86 | await page.keyboard.press('Control+z')
87 | await expect(me.getByText(originalText)).toBeVisible()
88 | await expect(me.getByText(newText)).toBeHidden()
89 |
90 | // Redo the edit operation
91 | await page.keyboard.press('Control+y')
92 | await expect(me.getByText(newText)).toBeVisible()
93 | await expect(me.getByText(originalText)).toBeHidden()
94 | })
95 |
96 | test('Undo/Redo - Multiple Operations Sequence', async ({ page, me }) => {
97 | // Perform multiple operations
98 | // 1. Add a child node
99 | await me.click(childTopic)
100 | await page.keyboard.press('Tab')
101 | await page.keyboard.press('Enter')
102 | await expect(me.getByText('New Node')).toBeVisible()
103 |
104 | // 2. Add a sibling node
105 | await page.keyboard.press('Enter')
106 | await page.keyboard.press('Enter')
107 | const newNodes = me.getByText('New Node')
108 | await expect(newNodes).toHaveCount(2)
109 |
110 | // 3. Edit the first new node
111 | await page.keyboard.press('ArrowUp')
112 | await page.keyboard.press('F2')
113 | await expect(page.locator('#input-box')).toBeVisible()
114 | await page.keyboard.insertText('First New Node')
115 | await page.keyboard.press('Enter')
116 | await expect(me.getByText('First New Node')).toBeVisible()
117 |
118 | // Now undo operations step by step
119 | // Undo edit operation
120 | await page.keyboard.press('Control+z')
121 | await expect(me.getByText('First New Node')).toBeHidden()
122 | await expect(me.getByText('New Node')).toHaveCount(2)
123 |
124 | // Undo second add operation
125 | await page.keyboard.press('Control+z')
126 | await expect(newNodes).toHaveCount(1)
127 |
128 | // Undo first add operation
129 | await page.keyboard.press('Control+z')
130 | await expect(me.getByText('New Node')).toBeHidden()
131 |
132 | // Redo all operations
133 | await page.keyboard.press('Control+y') // Redo first add
134 | await expect(me.getByText('New Node')).toBeVisible()
135 |
136 | await page.keyboard.press('Control+y') // Redo second add
137 | await expect(newNodes).toHaveCount(2)
138 |
139 | await page.keyboard.press('Control+y') // Redo edit
140 | await expect(me.getByText('First New Node')).toBeVisible()
141 | })
142 |
143 | test('Undo/Redo - Copy and Paste Operations', async ({ page, me }) => {
144 | // Copy middle node
145 | await me.click(middleTopic)
146 | await page.keyboard.press('Control+c')
147 |
148 | // Paste to child node
149 | await me.click(childTopic)
150 | await page.keyboard.press('Control+v')
151 |
152 | // Verify the copy was successful (should have two "middle" nodes)
153 | const middleNodes = me.getByText(middleTopic)
154 | await expect(middleNodes).toHaveCount(2)
155 |
156 | // Undo the paste operation
157 | await page.keyboard.press('Control+z')
158 | await expect(middleNodes).toHaveCount(1)
159 |
160 | // Redo the paste operation
161 | await page.keyboard.press('Control+y')
162 | await expect(middleNodes).toHaveCount(2)
163 | })
164 |
165 | test('Undo/Redo - Cut and Paste Operations', async ({ page, me }) => {
166 | // Cut child node
167 | await me.click(childTopic)
168 | await page.keyboard.press('Control+x')
169 | await expect(me.getByText(childTopic)).toBeHidden()
170 |
171 | // Paste to root node
172 | await me.click(topic)
173 | await page.keyboard.press('Control+v')
174 | await expect(me.getByText(childTopic)).toBeVisible()
175 |
176 | // Undo the paste operation
177 | await page.keyboard.press('Control+z')
178 | // After undo, the node should be back in its original position
179 |
180 | // Undo the cut operation
181 | await page.keyboard.press('Control+z')
182 | await expect(me.getByText(childTopic)).toBeVisible()
183 |
184 | // Redo the cut operation
185 | await page.keyboard.press('Control+y')
186 | await expect(me.getByText(childTopic)).toBeHidden()
187 |
188 | // Redo the paste operation
189 | await page.keyboard.press('Control+y')
190 | await expect(me.getByText(childTopic)).toBeVisible()
191 | })
192 |
193 | test('Undo/Redo - No Operations Available', async ({ page, me }) => {
194 | // Try to undo when no operations are available
195 | await page.keyboard.press('Control+z')
196 | // Should not crash or change anything
197 | await expect(me.getByText(topic)).toBeVisible()
198 | await expect(me.getByText(middleTopic)).toBeVisible()
199 | await expect(me.getByText(childTopic)).toBeVisible()
200 |
201 | // Try to redo when no operations are available
202 | await page.keyboard.press('Control+y')
203 | // Should not crash or change anything
204 | await expect(me.getByText(topic)).toBeVisible()
205 | await expect(me.getByText(middleTopic)).toBeVisible()
206 | await expect(me.getByText(childTopic)).toBeVisible()
207 | })
208 |
209 | test('Undo/Redo - Node Selection Restoration', async ({ page, me }) => {
210 | // Add a new node and verify it gets selected
211 | await me.click(childTopic)
212 | await page.keyboard.press('Enter')
213 | await page.keyboard.press('Enter')
214 |
215 | // The new node should be selected (we can verify this by checking if it has focus)
216 | const newNode = me.getByText('New Node')
217 | await expect(newNode).toBeVisible()
218 |
219 | // Undo the operation
220 | await page.keyboard.press('Control+z')
221 | await expect(newNode).toBeHidden()
222 |
223 | // The original child node should be selected again after undo
224 | // We can verify this by trying to perform an action that requires a selected node
225 | await page.keyboard.press('Delete')
226 | await expect(me.getByText(childTopic)).toBeHidden()
227 |
228 | // Undo the delete to restore the node
229 | await page.keyboard.press('Control+z')
230 | await expect(me.getByText(childTopic)).toBeVisible()
231 | })
232 |
--------------------------------------------------------------------------------
/src/utils/dom.ts:
--------------------------------------------------------------------------------
1 | import { LEFT } from '../const'
2 | import type { Topic, Wrapper, Parent, Children, Expander } from '../types/dom'
3 | import type { MindElixirInstance, NodeObj } from '../types/index'
4 | import { encodeHTML, getOffsetLT } from '../utils/index'
5 | import { layoutChildren } from './layout'
6 |
7 | // DOM manipulation
8 | const $d = document
9 | export const findEle = function (this: MindElixirInstance, id: string, el?: HTMLElement) {
10 | const scope = this?.el ? this.el : el ? el : document
11 | const ele = scope.querySelector(`[data-nodeid="me${id}"]`)
12 | if (!ele) throw new Error(`FindEle: Node ${id} not found, maybe it's collapsed.`)
13 | return ele
14 | }
15 |
16 | export const shapeTpc = function (this: MindElixirInstance, tpc: Topic, nodeObj: NodeObj) {
17 | tpc.innerHTML = ''
18 |
19 | if (nodeObj.style) {
20 | const style = nodeObj.style
21 | type KeyOfStyle = keyof typeof style
22 | for (const key in style) {
23 | tpc.style[key as KeyOfStyle] = style[key as KeyOfStyle]!
24 | }
25 | }
26 |
27 | if (nodeObj.dangerouslySetInnerHTML) {
28 | tpc.innerHTML = nodeObj.dangerouslySetInnerHTML
29 | return
30 | }
31 |
32 | if (nodeObj.image) {
33 | const img = nodeObj.image
34 | if (img.url && img.width && img.height) {
35 | const imgEl = $d.createElement('img')
36 | // Use imageProxy function if provided, otherwise use original URL
37 | imgEl.src = this.imageProxy ? this.imageProxy(img.url) : img.url
38 | imgEl.style.width = img.width + 'px'
39 | imgEl.style.height = img.height + 'px'
40 | if (img.fit) imgEl.style.objectFit = img.fit
41 | tpc.appendChild(imgEl)
42 | tpc.image = imgEl
43 | } else {
44 | console.warn('Image url/width/height are required')
45 | }
46 | } else if (tpc.image) {
47 | tpc.image = undefined
48 | }
49 |
50 | {
51 | const textEl = $d.createElement('span')
52 | textEl.className = 'text'
53 |
54 | // Check if markdown parser is provided and topic contains markdown syntax
55 | if (this.markdown) {
56 | textEl.innerHTML = this.markdown(nodeObj.topic, nodeObj)
57 | } else {
58 | textEl.textContent = nodeObj.topic
59 | }
60 |
61 | tpc.appendChild(textEl)
62 | tpc.text = textEl
63 | }
64 |
65 | if (nodeObj.hyperLink) {
66 | const linkEl = $d.createElement('a')
67 | linkEl.className = 'hyper-link'
68 | linkEl.target = '_blank'
69 | linkEl.innerText = '🔗'
70 | linkEl.href = nodeObj.hyperLink
71 | tpc.appendChild(linkEl)
72 | tpc.link = linkEl
73 | } else if (tpc.link) {
74 | tpc.link = undefined
75 | }
76 |
77 | if (nodeObj.icons && nodeObj.icons.length) {
78 | const iconsEl = $d.createElement('span')
79 | iconsEl.className = 'icons'
80 | iconsEl.innerHTML = nodeObj.icons.map(icon => `${encodeHTML(icon)}`).join('')
81 | tpc.appendChild(iconsEl)
82 | tpc.icons = iconsEl
83 | } else if (tpc.icons) {
84 | tpc.icons = undefined
85 | }
86 |
87 | if (nodeObj.tags && nodeObj.tags.length) {
88 | const tagsEl = $d.createElement('div')
89 | tagsEl.className = 'tags'
90 |
91 | nodeObj.tags.forEach(tag => {
92 | const span = $d.createElement('span')
93 |
94 | if (typeof tag === 'string') {
95 | span.textContent = tag
96 | } else {
97 | span.textContent = tag.text
98 | if (tag.className) {
99 | span.className = tag.className
100 | }
101 | if (tag.style) {
102 | Object.assign(span.style, tag.style)
103 | }
104 | }
105 |
106 | tagsEl.appendChild(span)
107 | })
108 |
109 | tpc.appendChild(tagsEl)
110 | tpc.tags = tagsEl
111 | } else if (tpc.tags) {
112 | tpc.tags = undefined
113 | }
114 | }
115 |
116 | // everything start from `Wrapper`
117 | export const createWrapper = function (this: MindElixirInstance, nodeObj: NodeObj, omitChildren?: boolean) {
118 | const grp = $d.createElement('me-wrapper') as Wrapper
119 | const { p, tpc } = this.createParent(nodeObj)
120 | grp.appendChild(p)
121 | if (!omitChildren && nodeObj.children && nodeObj.children.length > 0) {
122 | const expander = createExpander(nodeObj.expanded)
123 | p.appendChild(expander)
124 | // tpc.expander = expander
125 | if (nodeObj.expanded !== false) {
126 | const children = layoutChildren(this, nodeObj.children)
127 | grp.appendChild(children)
128 | }
129 | }
130 | return { grp, top: p, tpc }
131 | }
132 |
133 | export const createParent = function (this: MindElixirInstance, nodeObj: NodeObj) {
134 | const p = $d.createElement('me-parent') as Parent
135 | const tpc = this.createTopic(nodeObj)
136 | shapeTpc.call(this, tpc, nodeObj)
137 | p.appendChild(tpc)
138 | return { p, tpc }
139 | }
140 |
141 | export const createChildren = function (this: MindElixirInstance, wrappers: Wrapper[]) {
142 | const children = $d.createElement('me-children') as Children
143 | children.append(...wrappers)
144 | return children
145 | }
146 |
147 | export const createTopic = function (this: MindElixirInstance, nodeObj: NodeObj) {
148 | const topic = $d.createElement('me-tpc') as Topic
149 | topic.nodeObj = nodeObj
150 | topic.dataset.nodeid = 'me' + nodeObj.id
151 | topic.draggable = this.draggable
152 | return topic
153 | }
154 |
155 | export function selectText(div: HTMLElement) {
156 | const range = $d.createRange()
157 | range.selectNodeContents(div)
158 | const getSelection = window.getSelection()
159 | if (getSelection) {
160 | getSelection.removeAllRanges()
161 | getSelection.addRange(range)
162 | }
163 | }
164 |
165 | export const editTopic = function (this: MindElixirInstance, el: Topic) {
166 | console.time('editTopic')
167 | if (!el) return
168 | const div = $d.createElement('div')
169 | const node = el.nodeObj
170 |
171 | // Get the original content from topic
172 | const originalContent = node.topic
173 |
174 | // Use getOffsetLT to calculate el's offset relative to this.nodes
175 | const { offsetLeft, offsetTop } = getOffsetLT(this.nodes, el)
176 |
177 | // Insert input box into this.nodes instead of el
178 | this.nodes.appendChild(div)
179 | div.id = 'input-box'
180 | div.textContent = originalContent
181 | div.contentEditable = 'plaintext-only'
182 | div.spellcheck = false
183 | const style = getComputedStyle(el)
184 | div.style.cssText = `
185 | left: ${offsetLeft}px;
186 | top: ${offsetTop}px;
187 | min-width:${el.offsetWidth - 8}px;
188 | color:${style.color};
189 | font-size:${style.fontSize};
190 | padding:${style.padding};
191 | margin:${style.margin};
192 | background-color:${style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor};
193 | border: ${style.border};
194 | border-radius:${style.borderRadius}; `
195 | if (this.direction === LEFT) div.style.right = '0'
196 |
197 | selectText(div)
198 |
199 | this.bus.fire('operation', {
200 | name: 'beginEdit',
201 | obj: el.nodeObj,
202 | })
203 |
204 | div.addEventListener('keydown', e => {
205 | e.stopPropagation()
206 | const key = e.key
207 |
208 | if (key === 'Enter' || key === 'Tab') {
209 | // keep wrap for shift enter
210 | if (e.shiftKey) return
211 |
212 | e.preventDefault()
213 | div.blur()
214 | this.container.focus()
215 | }
216 | })
217 |
218 | div.addEventListener('blur', () => {
219 | if (!div) return
220 | div.remove()
221 | const inputContent = div.textContent?.trim() || ''
222 | if (inputContent === originalContent || inputContent === '') return
223 |
224 | // Update topic content
225 | node.topic = inputContent
226 |
227 | if (this.markdown) {
228 | el.text.innerHTML = this.markdown(node.topic, node)
229 | } else {
230 | el.text.textContent = inputContent
231 | }
232 |
233 | this.linkDiv()
234 | this.bus.fire('operation', {
235 | name: 'finishEdit',
236 | obj: node,
237 | origin: originalContent,
238 | })
239 | })
240 | console.timeEnd('editTopic')
241 | }
242 |
243 | export const createExpander = function (expanded: boolean | undefined): Expander {
244 | const expander = $d.createElement('me-epd') as Expander
245 | // if expanded is undefined, treat as expanded
246 | expander.expanded = expanded !== false
247 | expander.className = expanded !== false ? 'minus' : ''
248 | return expander
249 | }
250 |
--------------------------------------------------------------------------------
/tests/keyboard-undo-redo.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from './mind-elixir-test'
2 |
3 | const data = {
4 | nodeData: {
5 | topic: 'Root Node',
6 | id: 'root',
7 | children: [
8 | {
9 | id: 'left-1',
10 | topic: 'Left Branch 1',
11 | children: [
12 | {
13 | id: 'left-1-1',
14 | topic: 'Left Child 1',
15 | },
16 | {
17 | id: 'left-1-2',
18 | topic: 'Left Child 2',
19 | },
20 | ],
21 | },
22 | {
23 | id: 'right-1',
24 | topic: 'Right Branch 1',
25 | children: [
26 | {
27 | id: 'right-1-1',
28 | topic: 'Right Child 1',
29 | },
30 | ],
31 | },
32 | ],
33 | },
34 | }
35 |
36 | test.beforeEach(async ({ me }) => {
37 | await me.init(data)
38 | })
39 |
40 | test('Keyboard Shortcuts - Ctrl+Z for Undo', async ({ page, me }) => {
41 | // Perform an operation that can be undone
42 | await me.click('Left Child 1')
43 | await page.keyboard.press('Delete')
44 | await expect(page.getByText('Left Child 1')).toBeHidden()
45 |
46 | // Test Ctrl+Z
47 | await page.keyboard.press('Control+z')
48 | await expect(page.getByText('Left Child 1')).toBeVisible()
49 | })
50 |
51 | test('Keyboard Shortcuts - Ctrl+Y for Redo', async ({ page, me }) => {
52 | // Perform and undo an operation
53 | await me.click('Left Child 1')
54 | await page.keyboard.press('Delete')
55 | await page.keyboard.press('Control+z')
56 | await expect(page.getByText('Left Child 1')).toBeVisible()
57 |
58 | // Test Ctrl+Y
59 | await page.keyboard.press('Control+y')
60 | await expect(page.getByText('Left Child 1')).toBeHidden()
61 | })
62 |
63 | test('Keyboard Shortcuts - Ctrl+Shift+Z for Redo', async ({ page, me }) => {
64 | // Perform and undo an operation
65 | await me.click('Right Child 1')
66 | await page.keyboard.press('Tab') // Add child
67 | await page.keyboard.press('Enter')
68 | await expect(page.getByText('New Node')).toBeVisible()
69 |
70 | await page.keyboard.press('Control+z') // Undo
71 | await expect(page.getByText('New Node')).toBeHidden()
72 |
73 | // Test Ctrl+Shift+Z (alternative redo)
74 | await page.keyboard.press('Control+Shift+Z')
75 | await expect(page.getByText('New Node')).toBeVisible()
76 | })
77 |
78 | test('Keyboard Shortcuts - Meta+Z for Undo (Mac style)', async ({ page, me }) => {
79 | // This test simulates Mac-style shortcuts
80 | await me.click('Right Branch 1')
81 | await page.keyboard.press('Enter') // Add sibling
82 | await page.keyboard.press('Enter')
83 | await expect(page.getByText('New Node')).toBeVisible()
84 |
85 | // Test Meta+Z (Mac style undo)
86 | await page.keyboard.press('Meta+z')
87 | await expect(page.getByText('New Node')).toBeHidden()
88 | })
89 |
90 | test('Keyboard Shortcuts - Meta+Y for Redo (Mac style)', async ({ page, me }) => {
91 | // Perform and undo an operation
92 | await me.click('Left Branch 1')
93 | await page.keyboard.press('Shift+Enter') // Add before
94 | await page.keyboard.press('Enter')
95 | await expect(page.getByText('New Node')).toBeVisible()
96 |
97 | await page.keyboard.press('Meta+z') // Undo
98 | await expect(page.getByText('New Node')).toBeHidden()
99 |
100 | // Test Meta+Y (Mac style redo)
101 | await page.keyboard.press('Meta+y')
102 | await expect(page.getByText('New Node')).toBeVisible()
103 | })
104 |
105 | test('Keyboard Shortcuts - Meta+Shift+Z for Redo (Mac style)', async ({ page, me }) => {
106 | // Perform and undo an operation
107 | await me.click('Left Child 2')
108 | await page.keyboard.press('Control+Enter') // Add parent
109 | await page.keyboard.press('Enter')
110 | await expect(page.getByText('New Node')).toBeVisible()
111 |
112 | await page.keyboard.press('Meta+z') // Undo
113 | await expect(page.getByText('New Node')).toBeHidden()
114 |
115 | // Test Meta+Shift+Z (Mac style alternative redo)
116 | await page.keyboard.press('Meta+Shift+Z')
117 | await expect(page.getByText('New Node')).toBeVisible()
118 | })
119 |
120 | test('Keyboard Shortcuts - Rapid Undo/Redo Sequence', async ({ page, me }) => {
121 | // Perform multiple operations
122 | await me.click('Root Node')
123 |
124 | // Operation 1: Add child
125 | await page.keyboard.press('Tab')
126 | await page.keyboard.press('Enter')
127 | await expect(page.getByText('New Node')).toBeVisible()
128 |
129 | // Operation 2: Edit the new node
130 | await me.dblclick('New Node')
131 | await page.keyboard.press('Control+a')
132 | await page.keyboard.insertText('Edited Node')
133 | await page.keyboard.press('Enter')
134 | await expect(page.getByText('Edited Node')).toBeVisible()
135 |
136 | // Operation 3: Add sibling
137 | await page.keyboard.press('Enter')
138 | await page.keyboard.press('Enter')
139 | const newNodes = page.getByText('New Node')
140 | await expect(newNodes).toHaveCount(1)
141 |
142 | // Rapid undo sequence
143 | await page.keyboard.press('Control+z') // Undo add sibling
144 | await expect(newNodes).toHaveCount(0)
145 |
146 | await page.keyboard.press('Control+z') // Undo edit
147 | await expect(page.getByText('Edited Node')).toBeHidden()
148 | await expect(page.getByText('New Node')).toBeVisible()
149 |
150 | await page.keyboard.press('Control+z') // Undo add child
151 | await expect(page.getByText('New Node')).toBeHidden()
152 |
153 | // Rapid redo sequence
154 | await page.keyboard.press('Control+y') // Redo add child
155 | await expect(page.getByText('New Node')).toBeVisible()
156 |
157 | await page.keyboard.press('Control+y') // Redo edit
158 | await expect(page.getByText('Edited Node')).toBeVisible()
159 |
160 | await page.keyboard.press('Control+y') // Redo add sibling
161 | await expect(newNodes).toHaveCount(1)
162 | })
163 |
164 | test('Keyboard Shortcuts - Undo/Redo with Node Movement', async ({ page, me }) => {
165 | // Move a node using keyboard shortcuts
166 | await me.click('Left Child 1')
167 | await page.keyboard.press('Alt+ArrowUp') // Move up
168 |
169 | // Verify the node moved (this depends on the specific implementation)
170 | // We'll check by trying to undo the move
171 | await page.keyboard.press('Control+z')
172 |
173 | // Redo the move
174 | await page.keyboard.press('Control+y')
175 | })
176 |
177 | test('Keyboard Shortcuts - Undo/Redo Edge Cases', async ({ page, me }) => {
178 | // Test undo when at the beginning of history
179 | await page.keyboard.press('Control+z')
180 | await page.keyboard.press('Control+z')
181 | await page.keyboard.press('Control+z')
182 | // Should not crash or cause issues
183 | await expect(page.getByText('Root Node')).toBeVisible()
184 |
185 | // Perform an operation
186 | await me.click('Right Child 1')
187 | await page.keyboard.press('Delete')
188 | await expect(page.getByText('Right Child 1')).toBeHidden()
189 |
190 | // Test redo when at the end of history
191 | await page.keyboard.press('Control+y')
192 | await page.keyboard.press('Control+y')
193 | await page.keyboard.press('Control+y')
194 | // Should not crash or cause issues
195 | await expect(page.getByText('Right Child 1')).toBeHidden()
196 | })
197 |
198 | test('Keyboard Shortcuts - Undo/Redo with Complex Node Operations', async ({ page, me }) => {
199 | // Test with copy/paste operations
200 | await me.click('Left Branch 1')
201 | await page.keyboard.press('Control+c') // Copy
202 |
203 | await me.click('Right Branch 1')
204 | await page.keyboard.press('Control+v') // Paste
205 |
206 | // Should have two "Left Branch 1" nodes now
207 | const leftBranchNodes = page.getByText('Left Branch 1')
208 | await expect(leftBranchNodes).toHaveCount(2)
209 |
210 | // Undo the paste
211 | await page.keyboard.press('Control+z')
212 | await expect(leftBranchNodes).toHaveCount(1)
213 |
214 | // Redo the paste
215 | await page.keyboard.press('Control+y')
216 | await expect(leftBranchNodes).toHaveCount(2)
217 | })
218 |
219 | test('Keyboard Shortcuts - Undo/Redo Preserves Focus', async ({ page, me }) => {
220 | // Select a node and perform an operation
221 | await me.click('Left Child 2')
222 | await page.keyboard.press('Enter') // Add sibling
223 | await page.keyboard.press('Enter')
224 |
225 | // Undo should restore focus to the original node
226 | await page.keyboard.press('Control+z')
227 |
228 | // Test that the original node still has focus by performing an action
229 | await page.keyboard.press('Delete')
230 | await expect(page.getByText('Left Child 2')).toBeHidden()
231 |
232 | // Restore for cleanup
233 | await page.keyboard.press('Control+z')
234 | await expect(page.getByText('Left Child 2')).toBeVisible()
235 | })
236 |
--------------------------------------------------------------------------------