├── .husky ├── pre-push ├── pre-commit └── commit-msg ├── src ├── vite-env.d.ts ├── viselect │ ├── src │ │ ├── typings.d.ts │ │ ├── utils │ │ │ ├── arrayify.ts │ │ │ ├── domRect.ts │ │ │ ├── browser.ts │ │ │ ├── selectAll.ts │ │ │ ├── frames.ts │ │ │ ├── css.ts │ │ │ ├── intersects.ts │ │ │ ├── events.ts │ │ │ └── matchesTrigger.ts │ │ ├── EventEmitter.ts │ │ └── types.ts │ └── package.json ├── types │ ├── global.ts │ ├── dom.ts │ └── index.ts ├── icons │ ├── zoomout.svg │ ├── zoomin.svg │ ├── minus-circle.svg │ ├── add-circle.svg │ ├── living.svg │ ├── left.svg │ ├── right.svg │ ├── side.svg │ └── full.svg ├── utils │ ├── dragMoveHelper.ts │ ├── theme.ts │ ├── domManipulation.ts │ ├── generateBranch.ts │ ├── LinkDragMoveHelper.ts │ ├── objectManipulation.ts │ ├── layout.ts │ ├── pubsub.ts │ ├── index.ts │ ├── svg.ts │ └── dom.ts ├── plugin │ ├── toolBar.less │ ├── contextMenu.less │ ├── toolBar.ts │ ├── selection.ts │ ├── operationHistory.ts │ ├── nodeDraggable.ts │ └── contextMenu.ts ├── docs.ts ├── markdown.css ├── dev.dist.ts ├── const.ts ├── exampleData │ ├── htmlText.ts │ └── 2.ts ├── linkDiv.ts ├── methods.ts ├── i18n.ts ├── dev.ts └── index.ts ├── images ├── logo.png ├── logo2.png ├── screenshot.png ├── screenshot2.png ├── screenshot5.jpg └── screenshot.cn.png ├── tests ├── interaction.spec.ts-snapshots │ ├── Add-Before-1-chromium-win32.png │ ├── Add-Child-1-chromium-win32.png │ ├── Add-Parent-1-chromium-win32.png │ ├── Add-Sibling-1-chromium-win32.png │ ├── Edit-Node-1-chromium-win32.png │ ├── Remove-Node-1-chromium-win32.png │ ├── Clear-and-reset-1-chromium-win32.png │ └── Copy-and-Paste-1-chromium-win32.png ├── drag-and-drop.spec.ts-snapshots │ ├── DnD-move-in-1-chromium-win32.png │ ├── DnD-move-after-1-chromium-win32.png │ └── DnD-move-before-1-chromium-win32.png ├── multiple-node.spec.ts-snapshots │ ├── Multiple-Copy-1-chromium-win32.png │ ├── Multiple-Move-In-1-chromium-win32.png │ ├── Multiple-Move-After-1-chromium-win32.png │ └── Multiple-Move-Before-1-chromium-win32.png ├── multiple-instance.spec.ts-snapshots │ └── Add-Child-To-Data2-Correctly-1-chromium-win32.png ├── multiple-instance.spec.ts ├── drag-and-drop.spec.ts ├── mind-elixir-test.ts ├── topic-select.spec.ts ├── multiple-node.spec.ts ├── interaction.spec.ts ├── MindElixirFixture.ts ├── simple-undo-redo.spec.ts ├── expand-collapse.spec.ts ├── undo-redo.spec.ts └── keyboard-undo-redo.spec.ts ├── .prettierrc.json ├── .gitignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── npm-publish.yml ├── tsconfig.json ├── test.html ├── index.html ├── LICENSE ├── .npmignore ├── eslint.config.mjs ├── vite.config.ts ├── playwright.config.ts ├── package.json ├── index.css └── commitlint.config.js /.husky/pre-push: -------------------------------------------------------------------------------- 1 | pnpm test 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/images/logo2.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/images/screenshot.png -------------------------------------------------------------------------------- /images/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/images/screenshot2.png -------------------------------------------------------------------------------- /images/screenshot5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/images/screenshot5.jpg -------------------------------------------------------------------------------- /src/viselect/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const VERSION: string 3 | -------------------------------------------------------------------------------- /images/screenshot.cn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/images/screenshot.cn.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /src/viselect/src/utils/arrayify.ts: -------------------------------------------------------------------------------- 1 | // Turns a value into an array if it's not already an array 2 | export const arrayify = (value: T | T[]): T[] => (Array.isArray(value) ? value : [value]) 3 | -------------------------------------------------------------------------------- /tests/interaction.spec.ts-snapshots/Add-Before-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/interaction.spec.ts-snapshots/Add-Before-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/interaction.spec.ts-snapshots/Add-Child-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/interaction.spec.ts-snapshots/Add-Child-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/interaction.spec.ts-snapshots/Add-Parent-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/interaction.spec.ts-snapshots/Add-Parent-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/interaction.spec.ts-snapshots/Add-Sibling-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/interaction.spec.ts-snapshots/Add-Sibling-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/interaction.spec.ts-snapshots/Edit-Node-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/interaction.spec.ts-snapshots/Edit-Node-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/interaction.spec.ts-snapshots/Remove-Node-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/interaction.spec.ts-snapshots/Remove-Node-1-chromium-win32.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "arrowParens": "avoid", 7 | "printWidth": 150, 8 | "endOfLine": "auto" 9 | } -------------------------------------------------------------------------------- /tests/drag-and-drop.spec.ts-snapshots/DnD-move-in-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/drag-and-drop.spec.ts-snapshots/DnD-move-in-1-chromium-win32.png -------------------------------------------------------------------------------- /src/types/global.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | declare global { 3 | interface Element { 4 | setAttribute(name: string, value: boolean): void 5 | setAttribute(name: string, value: number): void 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/drag-and-drop.spec.ts-snapshots/DnD-move-after-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/drag-and-drop.spec.ts-snapshots/DnD-move-after-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/interaction.spec.ts-snapshots/Clear-and-reset-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/interaction.spec.ts-snapshots/Clear-and-reset-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/interaction.spec.ts-snapshots/Copy-and-Paste-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/interaction.spec.ts-snapshots/Copy-and-Paste-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/multiple-node.spec.ts-snapshots/Multiple-Copy-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/multiple-node.spec.ts-snapshots/Multiple-Copy-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/drag-and-drop.spec.ts-snapshots/DnD-move-before-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/drag-and-drop.spec.ts-snapshots/DnD-move-before-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/multiple-node.spec.ts-snapshots/Multiple-Move-In-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/multiple-node.spec.ts-snapshots/Multiple-Move-In-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/multiple-node.spec.ts-snapshots/Multiple-Move-After-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/multiple-node.spec.ts-snapshots/Multiple-Move-After-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/multiple-node.spec.ts-snapshots/Multiple-Move-Before-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/multiple-node.spec.ts-snapshots/Multiple-Move-Before-1-chromium-win32.png -------------------------------------------------------------------------------- /tests/multiple-instance.spec.ts-snapshots/Add-Child-To-Data2-Correctly-1-chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSShooter/mind-elixir-core/HEAD/tests/multiple-instance.spec.ts-snapshots/Add-Child-To-Data2-Correctly-1-chromium-win32.png -------------------------------------------------------------------------------- /src/viselect/src/utils/domRect.ts: -------------------------------------------------------------------------------- 1 | // Polyfill for DOMRect as happy-dom and jsdom don't support it 2 | export const domRect = (x = 0, y = 0, width = 0, height = 0): DOMRect => { 3 | const rect = { x, y, width, height, top: y, left: x, right: x + width, bottom: y + height } 4 | const toJSON = () => JSON.stringify(rect) 5 | return { ...rect, toJSON } 6 | } 7 | -------------------------------------------------------------------------------- /src/viselect/src/utils/browser.ts: -------------------------------------------------------------------------------- 1 | // Determines if the device's primary input supports touch 2 | // See this article: https://css-tricks.com/touch-devices-not-judged-size/ 3 | export const isTouchDevice = (): boolean => matchMedia('(hover: none), (pointer: coarse)').matches 4 | 5 | // Determines if the browser is safari 6 | export const isSafariBrowser = (): boolean => 'safari' in window 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | doc 5 | .nyc_output 6 | coverage 7 | api 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .eslintcache 27 | /test-results/ 28 | /playwright-report/ 29 | /blob-report/ 30 | /playwright/.cache/ 31 | 32 | /md -------------------------------------------------------------------------------- /src/icons/zoomout.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/dragMoveHelper.ts: -------------------------------------------------------------------------------- 1 | import type { MindElixirInstance } from '../types/index' 2 | 3 | export function createDragMoveHelper(mei: MindElixirInstance) { 4 | return { 5 | x: 0, 6 | y: 0, 7 | moved: false, // diffrentiate click and move 8 | mousedown: false, 9 | onMove(deltaX: number, deltaY: number) { 10 | if (this.mousedown) { 11 | this.moved = true 12 | mei.move(deltaX, deltaY) 13 | } 14 | }, 15 | clear() { 16 | this.mousedown = false 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ssshooter] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: ssshooter 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /src/viselect/src/utils/selectAll.ts: -------------------------------------------------------------------------------- 1 | import { arrayify } from './arrayify' 2 | 3 | export type SelectAllSelectors = (string | Element)[] | string | Element 4 | 5 | /** 6 | * Takes a selector (or array of selectors) and returns the matched nodes. 7 | * @param selector The selector or an Array of selectors. 8 | * @param doc 9 | * @returns {Array} Array of DOM-Nodes. 10 | */ 11 | export const selectAll = (selector: SelectAllSelectors, doc: Document = document): Element[] => 12 | arrayify(selector) 13 | .map(item => (typeof item === 'string' ? Array.from(doc.querySelectorAll(item)) : item instanceof Element ? item : null)) 14 | .flat() 15 | .filter(Boolean) as Element[] 16 | -------------------------------------------------------------------------------- /src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import { DARK_THEME, THEME } from '../const' 2 | import type { MindElixirInstance } from '../types/index' 3 | import type { Theme } from '../types/index' 4 | 5 | export const changeTheme = function (this: MindElixirInstance, theme: Theme, shouldRefresh = true) { 6 | this.theme = theme 7 | const base = theme.type === 'dark' ? DARK_THEME : THEME 8 | const cssVar = { 9 | ...base.cssVar, 10 | ...theme.cssVar, 11 | } 12 | const keys = Object.keys(cssVar) 13 | for (let i = 0; i < keys.length; i++) { 14 | const key = keys[i] as keyof typeof cssVar 15 | this.container.style.setProperty(key, cssVar[key] as string) 16 | } 17 | shouldRefresh && this.refresh() 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "declaration": true, 5 | "rootDir": "src", 6 | "outDir": "dist/types", 7 | "target": "ES2020", 8 | "useDefineForClassFields": true, 9 | "module": "ESNext", 10 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 11 | "skipLibCheck": true, 12 | 13 | /* Bundler mode */ 14 | "isolatedModules": true, 15 | "emitDeclarationOnly": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | 20 | "noFallthroughCasesInSwitch": true, 21 | "noErrorTruncation": true, 22 | "moduleResolution": "bundler" 23 | }, 24 | "include": ["src"], 25 | "exclude": ["src/dev.dist.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /src/icons/zoomin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/minus-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/plugin/toolBar.less: -------------------------------------------------------------------------------- 1 | .mind-elixir-toolbar { 2 | position: absolute; 3 | color: var(--panel-color); 4 | background: var(--panel-bgcolor); 5 | padding: 10px; 6 | border-radius: 5px; 7 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); 8 | 9 | svg { 10 | display: inline-block; // overwrite tailwindcss 11 | } 12 | span { 13 | &:active { 14 | opacity: 0.5; 15 | } 16 | } 17 | } 18 | 19 | .mind-elixir-toolbar.rb { 20 | right: 20px; 21 | bottom: 20px; 22 | 23 | span + span { 24 | margin-left: 10px; 25 | } 26 | } 27 | 28 | .mind-elixir-toolbar.lt { 29 | font-size: 20px; 30 | left: 20px; 31 | top: 20px; 32 | 33 | span { 34 | display: block; 35 | } 36 | 37 | span + span { 38 | margin-top: 10px; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/viselect/src/utils/frames.ts: -------------------------------------------------------------------------------- 1 | type AnyFunction = (...args: any[]) => void 2 | 3 | export interface Frames { 4 | next(...args: Parameters): void 5 | 6 | cancel(): void 7 | } 8 | 9 | export const frames = (fn: F): Frames => { 10 | let previousArgs: Parameters 11 | let frameId = -1 12 | let lock = false 13 | 14 | return { 15 | next: (...args: Parameters): void => { 16 | previousArgs = args 17 | 18 | if (!lock) { 19 | lock = true 20 | frameId = requestAnimationFrame(() => { 21 | fn(...previousArgs) 22 | lock = false 23 | }) 24 | } 25 | }, 26 | cancel: () => { 27 | cancelAnimationFrame(frameId) 28 | lock = false 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/docs.ts: -------------------------------------------------------------------------------- 1 | import type { Arrow } from './arrow' 2 | import type methods from './methods' 3 | import type { MindElixirMethods } from './methods' 4 | import type { Summary, SummarySvgGroup } from './summary' 5 | import type { MindElixirData, MindElixirInstance, NodeObj, NodeObjExport, Options, Theme, TagObj } from './types' 6 | import type { MainLineParams, SubLineParams } from './utils/generateBranch' 7 | import type { Locale } from './i18n' 8 | export { 9 | methods, 10 | Theme, 11 | Options, 12 | MindElixirMethods, 13 | MindElixirInstance, 14 | MindElixirData, 15 | NodeObj, 16 | NodeObjExport, 17 | Summary, 18 | SummarySvgGroup, 19 | Arrow, 20 | MainLineParams, 21 | SubLineParams, 22 | Locale, 23 | TagObj, 24 | } 25 | 26 | export type * from './types/dom' 27 | export type * from './utils/pubsub' 28 | -------------------------------------------------------------------------------- /src/icons/add-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/viselect/src/utils/css.ts: -------------------------------------------------------------------------------- 1 | const unitify = (val: string | number, unit = 'px'): string => { 2 | return typeof val === 'number' ? val + unit : val 3 | } 4 | 5 | /** 6 | * Add css to a DOM-Element or returns the current 7 | * value of a property. 8 | * 9 | * @param el The Element. 10 | * @param attr The attribute or an object which holds css key-properties. 11 | * @param val The value for a single attribute. 12 | * @returns {*} 13 | */ 14 | export const css = ( 15 | { style }: HTMLElement, 16 | attr: Partial> | string, 17 | val?: string | number 18 | ): void => { 19 | if (typeof attr === 'object') { 20 | for (const [key, value] of Object.entries(attr)) { 21 | if (value !== undefined) { 22 | style[key as any] = unitify(value) 23 | } 24 | } 25 | } else if (val !== undefined) { 26 | style[attr as any] = unitify(val) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/icons/living.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Mind Elixir 9 | 27 | 28 | 29 | 30 |
31 |
32 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/viselect/src/utils/intersects.ts: -------------------------------------------------------------------------------- 1 | export type Intersection = 'center' | 'cover' | 'touch' 2 | 3 | /** 4 | * Check if two DOM-Elements intersects each other. 5 | * @param a BoundingClientRect of the first element. 6 | * @param b BoundingClientRect of the second element. 7 | * @param mode Options are center, cover or touch. 8 | * @returns {boolean} If both elements intersects each other. 9 | */ 10 | export const intersects = (a: DOMRect, b: DOMRect, mode: Intersection = 'touch'): boolean => { 11 | switch (mode) { 12 | case 'center': { 13 | const bxc = b.left + b.width / 2 14 | const byc = b.top + b.height / 2 15 | 16 | return bxc >= a.left && bxc <= a.right && byc >= a.top && byc <= a.bottom 17 | } 18 | case 'cover': { 19 | return b.left >= a.left && b.top >= a.top && b.right <= a.right && b.bottom <= a.bottom 20 | } 21 | case 'touch': { 22 | return a.right >= b.left && a.left <= b.right && a.bottom >= b.top && a.top <= b.bottom 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Mind Elixir 9 | 10 | 28 | 29 | 30 | 31 |
32 | 33 |
34 |
35 |
36 |
37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/icons/left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 DjZhou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/viselect/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@viselect/vanilla", 3 | "version": "3.9.0", 4 | "description": "Simple, lightweight and modern library library for making visual DOM Selections.", 5 | "author": "Simon Reinisch ", 6 | "bugs": "https://github.com/simonwep/viselect/issues", 7 | "homepage": "https://github.com/simonwep/viselect#readme", 8 | "repository": "git+https://github.com/simonwep/viselect.git", 9 | "license": "MIT", 10 | "main": "./dist/viselect.umd.js", 11 | "module": "./dist/viselect.mjs", 12 | "types": "./dist/src/index.d.ts", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/src/index.d.ts", 16 | "import": "./dist/viselect.mjs", 17 | "require": "./dist/viselect.umd.js" 18 | } 19 | }, 20 | "scripts": { 21 | "build": "tsc && vite build", 22 | "dev": "vite" 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "keywords": [ 28 | "viselect", 29 | "typescript", 30 | "selecting", 31 | "selection", 32 | "user-selection", 33 | "user-interface", 34 | "ui-library", 35 | "ui" 36 | ], 37 | "publishConfig": { 38 | "access": "public" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/markdown.css: -------------------------------------------------------------------------------- 1 | /* Markdown in Mindmap */ 2 | .map-container h1 { 3 | font-size: 1.5rem; 4 | font-weight: 700; 5 | color: var(--selected); 6 | } 7 | 8 | .map-container h2 { 9 | font-size: 1.25rem; 10 | font-weight: 600; 11 | color: var(--selected); 12 | } 13 | 14 | .map-container h3 { 15 | font-size: 1.125rem; 16 | font-weight: 600; 17 | color: var(--selected); 18 | } 19 | 20 | .map-container h4 { 21 | font-size: 1rem; 22 | font-weight: 600; 23 | color: var(--selected); 24 | } 25 | 26 | .map-container h5 { 27 | font-size: 0.875rem; 28 | font-weight: 600; 29 | color: var(--selected); 30 | } 31 | 32 | .map-container h6 { 33 | font-size: 0.875rem; 34 | font-weight: 500; 35 | margin: 0.1rem 0; 36 | color: var(--selected); 37 | font-style: italic; 38 | } 39 | 40 | .map-container strong.asterisk-emphasis, 41 | .map-container em { 42 | color: var(--selected); 43 | } 44 | .map-container strong.underscore-emphasis { 45 | background: rgba(255, 235, 59, 0.25); /* 可改颜色/透明度 */ 46 | padding: 0.05em 0.15em; 47 | border-radius: 0.15em; 48 | } 49 | 50 | .map-container a { 51 | color: var(--selected); 52 | } 53 | 54 | .map-container a:hover { 55 | color: var(--selected); 56 | text-decoration: underline; 57 | } 58 | -------------------------------------------------------------------------------- /src/viselect/src/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | type AnyFunction = (...args: any[]) => any 2 | type EventMap = Record 3 | 4 | export class EventTarget { 5 | private readonly _listeners = new Map>() 6 | 7 | public addEventListener(event: K, cb: Events[K]): this { 8 | const set = this._listeners.get(event) ?? new Set() 9 | this._listeners.set(event, set) 10 | set.add(cb as AnyFunction) 11 | return this 12 | } 13 | 14 | public removeEventListener(event: K, cb: Events[K]): this { 15 | this._listeners.get(event)?.delete(cb as AnyFunction) 16 | return this 17 | } 18 | 19 | public dispatchEvent(event: K, ...data: Parameters): boolean { 20 | let ok = true 21 | for (const cb of this._listeners.get(event) ?? []) { 22 | ok = cb(...data) !== false && ok 23 | } 24 | 25 | return ok 26 | } 27 | 28 | public unbindAllListeners(): void { 29 | this._listeners.clear() 30 | } 31 | 32 | // Let's also support on, off and emit like node 33 | public on = this.addEventListener 34 | public off = this.removeEventListener 35 | public emit = this.dispatchEvent 36 | } 37 | -------------------------------------------------------------------------------- /tests/multiple-instance.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from './mind-elixir-test' 2 | import type MindElixir from '../src/index' 3 | 4 | declare let window: { 5 | E: typeof MindElixir.E 6 | } 7 | 8 | const data1 = { 9 | nodeData: { 10 | id: 'data1', 11 | topic: 'new topic', 12 | children: [], 13 | }, 14 | } 15 | 16 | const data2 = { 17 | nodeData: { 18 | id: 'data2', 19 | topic: 'new topic', 20 | children: [ 21 | { 22 | id: 'child', 23 | topic: 'child', 24 | direction: 0 as 0 | 1 | undefined, 25 | }, 26 | ], 27 | }, 28 | } 29 | test.beforeEach(async ({ me, page }) => { 30 | await me.init(data1, '#map') 31 | await me.init(data2, '#map2') 32 | }) 33 | 34 | // fix: https://github.com/SSShooter/mind-elixir-core/issues/247 35 | test('Add Child To Data2 Correctly', async ({ page, me }) => { 36 | const handle = await me.getInstance('#map2') 37 | handle.evaluateHandle(mei => 38 | mei.addChild(window.E('data2', document.body), { 39 | id: 'child2', 40 | topic: 'child2', 41 | }) 42 | ) 43 | handle.evaluateHandle(mei => 44 | mei.addChild(window.E('child', document.body), { 45 | id: 'child3', 46 | topic: 'child3', 47 | }) 48 | ) 49 | expect(await page.screenshot()).toMatchSnapshot() 50 | }) 51 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variables file 55 | .env 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | src/ 72 | webpack.config.js 73 | out/ 74 | doc/ -------------------------------------------------------------------------------- /tests/drag-and-drop.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from './mind-elixir-test' 2 | 3 | const m1 = 'm1' 4 | const m2 = 'm2' 5 | const childTopic = 'child-topic' 6 | const data = { 7 | nodeData: { 8 | topic: 'root-topic', 9 | id: 'root-id', 10 | children: [ 11 | { 12 | id: m1, 13 | topic: m1, 14 | children: [ 15 | { 16 | id: 'child', 17 | topic: childTopic, 18 | }, 19 | ], 20 | }, 21 | { 22 | id: m2, 23 | topic: m2, 24 | }, 25 | ], 26 | }, 27 | } 28 | 29 | test.beforeEach(async ({ me }) => { 30 | await me.init(data) 31 | }) 32 | 33 | test('DnD move before', async ({ page, me }) => { 34 | await page.getByText(m2).hover({ force: true }) 35 | await page.mouse.down() 36 | await me.dragOver(m1, 'before') 37 | await expect(page.locator('.insert-preview.before')).toBeVisible() 38 | await page.mouse.up() 39 | await me.toHaveScreenshot() 40 | }) 41 | 42 | test('DnD move after', async ({ page, me }) => { 43 | await page.getByText(m2).hover({ force: true }) 44 | await page.mouse.down() 45 | await me.dragOver(m1, 'after') 46 | await expect(page.locator('.insert-preview.after')).toBeVisible() 47 | await page.mouse.up() 48 | await me.toHaveScreenshot() 49 | }) 50 | 51 | test('DnD move in', async ({ page, me }) => { 52 | await page.getByText(m2).hover({ force: true }) 53 | await page.mouse.down() 54 | await me.dragOver(m1, 'in') 55 | await expect(page.locator('.insert-preview.in')).toBeVisible() 56 | await page.mouse.up() 57 | await me.toHaveScreenshot() 58 | }) 59 | -------------------------------------------------------------------------------- /src/plugin/contextMenu.less: -------------------------------------------------------------------------------- 1 | .map-container .context-menu { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | z-index: 99; 8 | .menu-list { 9 | position: fixed; 10 | list-style: none; 11 | margin: 0; 12 | padding: 0; 13 | color: var(--panel-color); 14 | box-shadow: 0 12px 15px 0 rgba(0, 0, 0, 0.2); 15 | border-radius: 5px; 16 | overflow: hidden; 17 | li { 18 | min-width: 200px; 19 | overflow: hidden; 20 | white-space: nowrap; 21 | padding: 6px 10px; 22 | background: var(--panel-bgcolor); 23 | border-bottom: 1px solid var(--panel-border-color); 24 | cursor: pointer; 25 | span { 26 | line-height: 20px; 27 | } 28 | a { 29 | color: #333; 30 | text-decoration: none; 31 | } 32 | &.disabled { 33 | display: none; 34 | } 35 | &:hover { 36 | filter: brightness(0.95); 37 | } 38 | &:last-child { 39 | border-bottom: 0; 40 | } 41 | span:last-child { 42 | float: right; 43 | } 44 | } 45 | } 46 | .key { 47 | font-size: 10px; 48 | background-color: #f1f1f1; 49 | color: #333; 50 | padding: 2px 5px; 51 | border-radius: 3px; 52 | } 53 | } 54 | 55 | .map-container .tips { 56 | position: absolute; 57 | bottom: 28px; 58 | left: 50%; 59 | transform: translateX(-50%); 60 | color: var(--panel-color); 61 | background: var(--panel-bgcolor); 62 | opacity: 0.8; 63 | padding: 5px 10px; 64 | border-radius: 5px; 65 | font-weight: bold; 66 | } 67 | -------------------------------------------------------------------------------- /src/types/dom.ts: -------------------------------------------------------------------------------- 1 | import type { Arrow } from '../arrow' 2 | import type { NodeObj } from './index' 3 | 4 | export interface Wrapper extends HTMLElement { 5 | firstChild: Parent 6 | children: HTMLCollection & [Parent, Children] 7 | parentNode: Children 8 | parentElement: Children 9 | offsetParent: Wrapper 10 | previousSibling: Wrapper | null 11 | nextSibling: Wrapper | null 12 | } 13 | 14 | export interface Parent extends HTMLElement { 15 | firstChild: Topic 16 | children: HTMLCollection & [Topic, Expander | undefined] 17 | parentNode: Wrapper 18 | parentElement: Wrapper 19 | nextSibling: Children 20 | offsetParent: Wrapper 21 | } 22 | 23 | export interface Children extends HTMLElement { 24 | parentNode: Wrapper 25 | children: HTMLCollection & Wrapper[] 26 | parentElement: Wrapper 27 | firstChild: Wrapper 28 | previousSibling: Parent 29 | } 30 | 31 | export interface Topic extends HTMLElement { 32 | nodeObj: NodeObj 33 | parentNode: Parent 34 | parentElement: Parent 35 | offsetParent: Parent 36 | 37 | text: HTMLSpanElement 38 | expander?: Expander 39 | 40 | link?: HTMLElement 41 | image?: HTMLImageElement 42 | icons?: HTMLSpanElement 43 | tags?: HTMLDivElement 44 | } 45 | 46 | export interface Expander extends HTMLElement { 47 | expanded?: boolean 48 | parentNode: Parent 49 | parentElement: Parent 50 | previousSibling: Topic 51 | } 52 | 53 | export type CustomLine = SVGPathElement 54 | export type CustomArrow = SVGPathElement 55 | export interface CustomSvg extends SVGGElement { 56 | arrowObj: Arrow 57 | labelEl?: HTMLDivElement // Reference to the label div element 58 | line: SVGPathElement 59 | arrow1: SVGPathElement 60 | arrow2: SVGPathElement 61 | } 62 | -------------------------------------------------------------------------------- /src/viselect/src/utils/events.ts: -------------------------------------------------------------------------------- 1 | import { arrayify } from './arrayify' 2 | 3 | type Method = 'addEventListener' | 'removeEventListener' 4 | type AnyFunction = (...arg: any) => any 5 | 6 | const eventListener = 7 | (method: Method) => 8 | (items: (EventTarget | undefined) | (EventTarget | undefined)[], events: string | string[], fn: AnyFunction, options = {}) => { 9 | // Normalize array 10 | if (items instanceof HTMLCollection || items instanceof NodeList) { 11 | items = Array.from(items) 12 | } 13 | 14 | events = arrayify(events) 15 | items = arrayify(items) 16 | 17 | for (const el of items) { 18 | if (el) { 19 | for (const ev of events) { 20 | el[method](ev, fn as EventListener, { capture: false, ...options }) 21 | } 22 | } 23 | } 24 | } 25 | 26 | /** 27 | * Add event(s) to element(s). 28 | * @param elements DOM-Elements 29 | * @param events Event names 30 | * @param fn Callback 31 | * @param options Optional options 32 | * @return Array passed arguments 33 | */ 34 | export const on = eventListener('addEventListener') 35 | 36 | /** 37 | * Remove event(s) from element(s). 38 | * @param elements DOM-Elements 39 | * @param events Event names 40 | * @param fn Callback 41 | * @param options Optional options 42 | * @return Array passed arguments 43 | */ 44 | export const off = eventListener('removeEventListener') 45 | 46 | /** 47 | * Simplifies a touch / mouse-event 48 | * @param evt 49 | */ 50 | export const simplifyEvent = ( 51 | evt: any 52 | ): { 53 | target: HTMLElement 54 | x: number 55 | y: number 56 | } => { 57 | const { clientX, clientY, target } = evt.touches?.[0] ?? evt 58 | return { x: clientX, y: clientY, target } 59 | } 60 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from 'eslint/config' 2 | import globals from 'globals' 3 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 4 | import tsParser from '@typescript-eslint/parser' 5 | import js from '@eslint/js' 6 | import { FlatCompat } from '@eslint/eslintrc' 7 | import { fileURLToPath } from 'url' 8 | import { dirname } from 'path' 9 | 10 | const __filename = fileURLToPath(import.meta.url) 11 | const __dirname = dirname(__filename) 12 | 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all, 17 | }) 18 | 19 | export default defineConfig([ 20 | { 21 | languageOptions: { 22 | globals: { 23 | ...globals.browser, 24 | ...globals.node, 25 | }, 26 | 27 | parser: tsParser, 28 | sourceType: 'module', 29 | parserOptions: {}, 30 | }, 31 | 32 | extends: compat.extends('eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'), 33 | 34 | plugins: { 35 | '@typescript-eslint': typescriptEslint, 36 | }, 37 | 38 | rules: { 39 | '@typescript-eslint/consistent-type-imports': 'error', 40 | '@typescript-eslint/no-non-null-assertion': 'off', 41 | '@typescript-eslint/no-explicit-any': 'off', 42 | '@typescript-eslint/no-unused-vars': 'off', 43 | '@typescript-eslint/no-unused-expressions': [ 44 | 'error', 45 | { 46 | allowShortCircuit: true, 47 | allowTernary: true, 48 | }, 49 | ], 50 | }, 51 | }, 52 | globalIgnores(['**/dist', 'src/__tests__', '**/index.html', '**/test.html']), 53 | ]) 54 | -------------------------------------------------------------------------------- /src/dev.dist.ts: -------------------------------------------------------------------------------- 1 | import MindElixir from 'mind-elixir' 2 | import example from 'mind-elixir/example' 3 | import type { Options } from 'mind-elixir' 4 | 5 | const E = MindElixir.E 6 | const options: Options = { 7 | el: '#map', 8 | newTopicName: '子节点', 9 | // direction: MindElixir.LEFT, 10 | direction: MindElixir.RIGHT, 11 | // data: MindElixir.new('new topic'), 12 | locale: 'en', 13 | draggable: true, 14 | editable: true, 15 | contextMenu: { 16 | focus: true, 17 | link: true, 18 | extend: [ 19 | { 20 | name: 'Node edit', 21 | onclick: () => { 22 | alert('extend menu') 23 | }, 24 | }, 25 | ], 26 | }, 27 | toolBar: true, 28 | keypress: true, 29 | allowUndo: false, 30 | before: { 31 | moveDownNode() { 32 | return false 33 | }, 34 | insertSibling(el, obj) { 35 | console.log('insertSibling', el, obj) 36 | return true 37 | }, 38 | async addChild(el, obj) { 39 | return true 40 | }, 41 | }, 42 | scaleSensitivity: 0.2, 43 | } 44 | const mind = new MindElixir(options) 45 | mind.init(example) 46 | function sleep() { 47 | return new Promise(res => { 48 | setTimeout(() => res(), 1000) 49 | }) 50 | } 51 | console.log('test E function', E('bd4313fbac40284b')) 52 | // let mind2 = new MindElixir({ 53 | // el: '#map2', 54 | // direction: 2, 55 | // data: MindElixir.example2, 56 | // draggable: false, 57 | // // overflowHidden: true, 58 | // nodeMenu: true, 59 | // }) 60 | // mind2.init() 61 | 62 | mind.bus.addListener('operation', operation => { 63 | console.log(operation) 64 | }) 65 | 66 | mind.bus.addListener('selectNodes', nodes => { 67 | console.log(nodes) 68 | }) 69 | -------------------------------------------------------------------------------- /tests/mind-elixir-test.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test' 2 | import { MindElixirFixture } from './MindElixirFixture' 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import * as crypto from 'crypto'; 6 | 7 | const istanbulCLIOutput = path.join(process.cwd(), '.nyc_output'); 8 | 9 | export function generateUUID(): string { 10 | return crypto.randomBytes(16).toString('hex'); 11 | } 12 | 13 | // Declare the types of your fixtures. 14 | type MyFixtures = { 15 | me: MindElixirFixture 16 | } 17 | 18 | // Extend base test by providing "todoPage" and "settingsPage". 19 | // This new "test" can be used in multiple test files, and each of them will get the fixtures. 20 | export const test = base.extend({ 21 | context: async ({ context }, use) => { 22 | await context.addInitScript(() => 23 | window.addEventListener('beforeunload', () => 24 | (window as any).collectIstanbulCoverage(JSON.stringify((window as any).__coverage__)) 25 | ), 26 | ); 27 | await fs.promises.mkdir(istanbulCLIOutput, { recursive: true }); 28 | await context.exposeFunction('collectIstanbulCoverage', (coverageJSON: string) => { 29 | if (coverageJSON) 30 | fs.writeFileSync(path.join(istanbulCLIOutput, `playwright_coverage_${generateUUID()}.json`), coverageJSON); 31 | }); 32 | await use(context); 33 | for (const page of context.pages()) { 34 | await page.evaluate(() => (window as any).collectIstanbulCoverage(JSON.stringify((window as any).__coverage__))) 35 | } 36 | }, 37 | me: async ({ page }, use) => { 38 | // Set up the fixture. 39 | const me = new MindElixirFixture(page) 40 | await me.goto() 41 | 42 | // Use the fixture value in the test. 43 | await use(me) 44 | }, 45 | }) 46 | export { expect } from '@playwright/test' 47 | -------------------------------------------------------------------------------- /src/viselect/src/utils/matchesTrigger.ts: -------------------------------------------------------------------------------- 1 | // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button#value 2 | export type MouseButton = 3 | | 0 // Main 4 | | 1 // Auxiliary 5 | | 2 // Secondary 6 | | 3 // Fourth 7 | | 4 // Fifth 8 | 9 | export type Modifier = 'ctrl' | 'alt' | 'shift' 10 | 11 | export type MouseButtonWithModifiers = { 12 | button: MouseButton 13 | modifiers: Modifier[] 14 | } 15 | 16 | export type Trigger = MouseButton | MouseButtonWithModifiers 17 | 18 | /** 19 | * Determines whether a MouseEvent should execute until completion depending on 20 | * which button and modifier(s) are active for the MouseEvent. 21 | * The Event will execute to completion if ANY of the triggers "matches" 22 | * @param event MouseEvent that should be checked 23 | * @param triggers A list of Triggers that signify that the event should execute until completion 24 | * @returns Whether the MouseEvent should execute until completion 25 | */ 26 | export const matchesTrigger = (event: MouseEvent, triggers: Trigger[]): boolean => 27 | triggers.some(trigger => { 28 | // The trigger requires only a specific button to be pressed 29 | if (typeof trigger === 'number') { 30 | return event.button === trigger 31 | } 32 | 33 | // The trigger requires a specific button to be pressed AND some modifiers 34 | if (typeof trigger === 'object') { 35 | if (trigger.button !== event.button) { 36 | return false 37 | } 38 | 39 | return trigger.modifiers.every(modifier => { 40 | switch (modifier) { 41 | case 'alt': 42 | return event.altKey 43 | case 'ctrl': 44 | return event.ctrlKey || event.metaKey 45 | case 'shift': 46 | return event.shiftKey 47 | } 48 | }) 49 | } 50 | 51 | return false 52 | }) 53 | -------------------------------------------------------------------------------- /src/icons/side.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | import { defineConfig } from 'vite' 3 | import istanbul from 'vite-plugin-istanbul' 4 | 5 | export default defineConfig({ 6 | server: { 7 | host: true, 8 | port: 23333, 9 | strictPort: true, 10 | }, 11 | plugins: [ 12 | istanbul({ 13 | include: 'src/*', 14 | exclude: ['node_modules', 'test/', 'src/plugin/exportImage.ts'], 15 | extension: ['.ts'], 16 | requireEnv: true, 17 | }), 18 | ], 19 | // build: { 20 | // cssCodeSplit: false, 21 | // lib: { 22 | // // Could also be a dictionary or array of multiple entry points 23 | // entry: { 24 | // MindElixir: resolve(__dirname, './src/index.ts'), 25 | // MindElixirLite: resolve(__dirname, './src/index.lite.ts'), 26 | // example1: resolve(__dirname, './src/exampleData/1.ts'), 27 | // example2: resolve(__dirname, './src/exampleData/2.ts'), 28 | // }, 29 | // name: 'MindElixir', 30 | // // formats: ['es'], 31 | // }, 32 | // rollupOptions: { 33 | // // make sure to externalize deps that shouldn't be bundled 34 | // // into your library 35 | // // external: ['vue'], 36 | // // output: { 37 | // // // Provide global variables to use in the UMD build 38 | // // // for externalized deps 39 | // // globals: { 40 | // // vue: 'Vue', 41 | // // }, 42 | // // }, 43 | // output: [ 44 | // { 45 | // dir: 'dist', 46 | // // file: 'bundle.js', 47 | // format: 'iife', 48 | // name: 'MyBundle', 49 | // inlineDynamicImports: true, 50 | // }, 51 | // { 52 | // dir: 'dist', 53 | // // file: 'bundle.js', 54 | // format: 'es', 55 | // name: 'MyBundle', 56 | // inlineDynamicImports: true, 57 | // }, 58 | // ], 59 | // }, 60 | // }, 61 | }) 62 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | import type { Theme } from '.' 2 | 3 | export const LEFT = 0 4 | export const RIGHT = 1 5 | export const SIDE = 2 6 | export const DOWN = 3 7 | 8 | export const THEME: Theme = { 9 | name: 'Latte', 10 | type: 'light', 11 | palette: ['#dd7878', '#ea76cb', '#8839ef', '#e64553', '#fe640b', '#df8e1d', '#40a02b', '#209fb5', '#1e66f5', '#7287fd'], 12 | cssVar: { 13 | '--node-gap-x': '30px', 14 | '--node-gap-y': '10px', 15 | '--main-gap-x': '65px', 16 | '--main-gap-y': '45px', 17 | '--root-radius': '30px', 18 | '--main-radius': '20px', 19 | '--root-color': '#ffffff', 20 | '--root-bgcolor': '#4c4f69', 21 | '--root-border-color': 'rgba(0, 0, 0, 0)', 22 | '--main-color': '#444446', 23 | '--main-bgcolor': '#ffffff', 24 | '--topic-padding': '3px', 25 | '--color': '#777777', 26 | '--bgcolor': '#f6f6f6', 27 | '--selected': '#4dc4ff', 28 | '--accent-color': '#e64553', 29 | '--panel-color': '#444446', 30 | '--panel-bgcolor': '#ffffff', 31 | '--panel-border-color': '#eaeaea', 32 | '--map-padding': '50px', 33 | }, 34 | } 35 | 36 | export const DARK_THEME: Theme = { 37 | name: 'Dark', 38 | type: 'dark', 39 | palette: ['#848FA0', '#748BE9', '#D2F9FE', '#4145A5', '#789AFA', '#706CF4', '#EF987F', '#775DD5', '#FCEECF', '#DA7FBC'], 40 | cssVar: { 41 | '--node-gap-x': '30px', 42 | '--node-gap-y': '10px', 43 | '--main-gap-x': '65px', 44 | '--main-gap-y': '45px', 45 | '--root-radius': '30px', 46 | '--main-radius': '20px', 47 | '--root-color': '#ffffff', 48 | '--root-bgcolor': '#2d3748', 49 | '--root-border-color': 'rgba(255, 255, 255, 0.1)', 50 | '--main-color': '#ffffff', 51 | '--main-bgcolor': '#4c4f69', 52 | '--topic-padding': '3px', 53 | '--color': '#cccccc', 54 | '--bgcolor': '#252526', 55 | '--selected': '#4dc4ff', 56 | '--accent-color': '#789AFA', 57 | '--panel-color': '#ffffff', 58 | '--panel-bgcolor': '#2d3748', 59 | '--panel-border-color': '#696969', 60 | '--map-padding': '50px 80px', 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | workflow_dispatch: 10 | 11 | permissions: 12 | id-token: write # Required for OIDC 13 | contents: read 14 | 15 | jobs: 16 | publish: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: pnpm/action-setup@v4 21 | with: 22 | version: 9 23 | - uses: actions/setup-node@v6 24 | with: 25 | node-version: 24 26 | registry-url: 'https://registry.npmjs.org' 27 | cache: 'pnpm' 28 | - name: Upgrade npm for OIDC support 29 | run: npm install -g npm@latest 30 | - run: pnpm i 31 | - run: npm run build 32 | - run: NODE_AUTH_TOKEN="" npm publish 33 | 34 | - name: Checkout docs repository 35 | uses: actions/checkout@v3 36 | with: 37 | repository: mind-elixir/docs 38 | token: ${{ secrets.PAT }} 39 | path: me-docs 40 | 41 | - name: Generate API documentation 42 | run: | 43 | npm run doc 44 | npm run doc:md 45 | 46 | - name: Copy build results to docs repository 47 | run: | 48 | cp -r ./md/* ./me-docs/docs/api 49 | cp -r ./md/* ./me-docs/i18n/ja/docusaurus-plugin-content-docs/current/api 50 | cp -r ./md/* ./me-docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/api 51 | 52 | - name: Push changes to docs repository 53 | run: | 54 | cd me-docs 55 | git config user.name "github-actions[bot]" 56 | git config user.email "github-actions[bot]@users.noreply.github.com" 57 | git add . 58 | if git diff-index --quiet HEAD; then 59 | echo "No changes to commit" 60 | else 61 | git commit -m "Update API documentation" 62 | git push 63 | fi 64 | 65 | -------------------------------------------------------------------------------- /src/utils/domManipulation.ts: -------------------------------------------------------------------------------- 1 | import { LEFT, RIGHT, SIDE } from '../const' 2 | import { rmSubline } from '../nodeOperation' 3 | import type { MindElixirInstance, NodeObj } from '../types' 4 | import type { Topic, Wrapper } from '../types/dom' 5 | import { createExpander } from './dom' 6 | 7 | // Judge new added node L or R 8 | export const judgeDirection = function ({ map, direction }: MindElixirInstance, obj: NodeObj) { 9 | if (direction === LEFT) { 10 | return LEFT 11 | } else if (direction === RIGHT) { 12 | return RIGHT 13 | } else if (direction === SIDE) { 14 | const l = map.querySelector('.lhs')?.childElementCount || 0 15 | const r = map.querySelector('.rhs')?.childElementCount || 0 16 | if (l <= r) { 17 | obj.direction = LEFT 18 | return LEFT 19 | } else { 20 | obj.direction = RIGHT 21 | return RIGHT 22 | } 23 | } 24 | } 25 | 26 | export const addChildDom = function (mei: MindElixirInstance, to: Topic, wrapper: Wrapper) { 27 | const tpc = wrapper.children[0].children[0] 28 | const top = to.parentElement 29 | if (top.tagName === 'ME-PARENT') { 30 | rmSubline(tpc) 31 | if (top.children[1]) { 32 | top.nextSibling.appendChild(wrapper) 33 | } else { 34 | const c = mei.createChildren([wrapper]) 35 | top.appendChild(createExpander(true)) 36 | top.insertAdjacentElement('afterend', c) 37 | } 38 | mei.linkDiv(wrapper.offsetParent as Wrapper) 39 | } else if (top.tagName === 'ME-ROOT') { 40 | const direction = judgeDirection(mei, tpc.nodeObj) 41 | if (direction === LEFT) { 42 | mei.container.querySelector('.lhs')?.appendChild(wrapper) 43 | } else { 44 | mei.container.querySelector('.rhs')?.appendChild(wrapper) 45 | } 46 | mei.linkDiv() 47 | } 48 | } 49 | 50 | export const removeNodeDom = function (tpc: Topic, siblingLength: number) { 51 | const p = tpc.parentNode 52 | if (siblingLength === 0) { 53 | // remove epd when children length === 0 54 | const c = p.parentNode.parentNode 55 | if (c.tagName !== 'ME-MAIN') { 56 | // Root 57 | c.previousSibling.children[1]!.remove() // remove epd 58 | c.remove() // remove Children div 59 | } 60 | } 61 | p.parentNode.remove() 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/generateBranch.ts: -------------------------------------------------------------------------------- 1 | import type { MindElixirInstance } from '..' 2 | import { DirectionClass } from '../types/index' 3 | 4 | export interface MainLineParams { 5 | pT: number 6 | pL: number 7 | pW: number 8 | pH: number 9 | cT: number 10 | cL: number 11 | cW: number 12 | cH: number 13 | direction: DirectionClass 14 | containerHeight: number 15 | } 16 | 17 | export interface SubLineParams { 18 | pT: number 19 | pL: number 20 | pW: number 21 | pH: number 22 | cT: number 23 | cL: number 24 | cW: number 25 | cH: number 26 | direction: DirectionClass 27 | isFirst: boolean | undefined 28 | } 29 | 30 | // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#path_commands 31 | 32 | export function main({ pT, pL, pW, pH, cT, cL, cW, cH, direction, containerHeight }: MainLineParams) { 33 | let x1 = pL + pW / 2 34 | const y1 = pT + pH / 2 35 | let x2 36 | if (direction === DirectionClass.LHS) { 37 | x2 = cL + cW 38 | } else { 39 | x2 = cL 40 | } 41 | const y2 = cT + cH / 2 42 | const pct = Math.abs(y2 - y1) / containerHeight 43 | const offset = (1 - pct) * 0.25 * (pW / 2) 44 | if (direction === DirectionClass.LHS) { 45 | x1 = x1 - pW / 10 - offset 46 | } else { 47 | x1 = x1 + pW / 10 + offset 48 | } 49 | return `M ${x1} ${y1} Q ${x1} ${y2} ${x2} ${y2}` 50 | } 51 | 52 | export function sub(this: MindElixirInstance, { pT, pL, pW, pH, cT, cL, cW, cH, direction, isFirst }: SubLineParams) { 53 | const GAP = parseInt(this.container.style.getPropertyValue('--node-gap-x')) // cache? 54 | // const GAP = 30 55 | let y1 = 0 56 | let end = 0 57 | if (isFirst) { 58 | y1 = pT + pH / 2 59 | } else { 60 | y1 = pT + pH 61 | } 62 | const y2 = cT + cH 63 | let x1 = 0 64 | let x2 = 0 65 | let xMid = 0 66 | const offset = (Math.abs(y1 - y2) / 300) * GAP 67 | if (direction === DirectionClass.LHS) { 68 | xMid = pL 69 | x1 = xMid + GAP 70 | x2 = xMid - GAP 71 | end = cL + GAP 72 | return `M ${x1} ${y1} C ${xMid} ${y1} ${xMid + offset} ${y2} ${x2} ${y2} H ${end}` 73 | } else { 74 | xMid = pL + pW 75 | x1 = xMid - GAP 76 | x2 = xMid + GAP 77 | end = cL + cW - GAP 78 | return `M ${x1} ${y1} C ${xMid} ${y1} ${xMid - offset} ${y2} ${x2} ${y2} H ${end}` 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/LinkDragMoveHelper.ts: -------------------------------------------------------------------------------- 1 | import { on } from '.' 2 | 3 | const create = function (dom: HTMLElement) { 4 | return { 5 | dom, 6 | moved: false, // differentiate click and move 7 | pointerdown: false, 8 | lastX: 0, 9 | lastY: 0, 10 | handlePointerMove(e: PointerEvent) { 11 | if (this.pointerdown) { 12 | this.moved = true 13 | // Calculate delta manually since pointer events don't have movementX/Y 14 | const deltaX = e.clientX - this.lastX 15 | const deltaY = e.clientY - this.lastY 16 | this.lastX = e.clientX 17 | this.lastY = e.clientY 18 | this.cb && this.cb(deltaX, deltaY) 19 | } 20 | }, 21 | handlePointerDown(e: PointerEvent) { 22 | if (e.button !== 0) return 23 | this.pointerdown = true 24 | this.lastX = e.clientX 25 | this.lastY = e.clientY 26 | // Set pointer capture for better tracking 27 | this.dom.setPointerCapture(e.pointerId) 28 | }, 29 | handleClear(e: PointerEvent) { 30 | this.pointerdown = false 31 | // Release pointer capture 32 | if (e.pointerId !== undefined) { 33 | this.dom.releasePointerCapture(e.pointerId) 34 | } 35 | }, 36 | cb: null as ((deltaX: number, deltaY: number) => void) | null, 37 | init(map: HTMLElement, cb: (deltaX: number, deltaY: number) => void) { 38 | this.cb = cb 39 | this.handleClear = this.handleClear.bind(this) 40 | this.handlePointerMove = this.handlePointerMove.bind(this) 41 | this.handlePointerDown = this.handlePointerDown.bind(this) 42 | this.destroy = on([ 43 | { dom: map, evt: 'pointermove', func: this.handlePointerMove }, 44 | { dom: map, evt: 'pointerleave', func: this.handleClear }, 45 | { dom: map, evt: 'pointerup', func: this.handleClear }, 46 | { dom: this.dom, evt: 'pointerdown', func: this.handlePointerDown }, 47 | ]) 48 | }, 49 | destroy: null as (() => void) | null, 50 | clear() { 51 | this.moved = false 52 | this.pointerdown = false 53 | }, 54 | } 55 | } 56 | const LinkDragMoveHelper = { 57 | create, 58 | } 59 | 60 | export type LinkDragMoveHelperInstance = ReturnType 61 | export default LinkDragMoveHelper 62 | -------------------------------------------------------------------------------- /src/icons/full.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | timeout: 10000, 14 | testDir: './tests', 15 | /* Run tests in files in parallel */ 16 | fullyParallel: true, 17 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 18 | forbidOnly: !!process.env.CI, 19 | /* Retry on CI only */ 20 | retries: process.env.CI ? 2 : 0, 21 | /* Opt out of parallel tests on CI. */ 22 | workers: process.env.CI ? 1 : undefined, 23 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 24 | // reporter: 'html', 25 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 26 | use: { 27 | /* Base URL to use in actions like `await page.goto('/')`. */ 28 | // baseURL: 'http://127.0.0.1:3000', 29 | 30 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 31 | trace: 'on-first-retry', 32 | }, 33 | 34 | /* Configure projects for major browsers */ 35 | projects: [ 36 | { 37 | name: 'chromium', 38 | use: { ...devices['Desktop Chrome'] }, 39 | }, 40 | 41 | // { 42 | // name: 'firefox', 43 | // use: { ...devices['Desktop Firefox'] }, 44 | // }, 45 | 46 | // { 47 | // name: 'webkit', 48 | // use: { ...devices['Desktop Safari'] }, 49 | // }, 50 | 51 | /* Test against mobile viewports. */ 52 | // { 53 | // name: 'Mobile Chrome', 54 | // use: { ...devices['Pixel 5'] }, 55 | // }, 56 | // { 57 | // name: 'Mobile Safari', 58 | // use: { ...devices['iPhone 12'] }, 59 | // }, 60 | 61 | /* Test against branded browsers. */ 62 | // { 63 | // name: 'Microsoft Edge', 64 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 65 | // }, 66 | // { 67 | // name: 'Google Chrome', 68 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 69 | // }, 70 | ], 71 | 72 | /* Run your local dev server before starting the tests */ 73 | webServer: { 74 | command: 'pnpm dev --port 23334', 75 | port: 23334, 76 | reuseExistingServer: true, 77 | env: { 78 | VITE_COVERAGE: 'true' 79 | }, 80 | }, 81 | }) 82 | -------------------------------------------------------------------------------- /src/utils/objectManipulation.ts: -------------------------------------------------------------------------------- 1 | import type { NodeObj } from '../types' 2 | 3 | const getSibling = (obj: NodeObj): { siblings: NodeObj[] | undefined; index: number } => { 4 | const siblings = obj.parent?.children as NodeObj[] 5 | const index = siblings?.indexOf(obj) ?? 0 6 | return { siblings, index } 7 | } 8 | 9 | export function moveUpObj(obj: NodeObj) { 10 | const { siblings, index } = getSibling(obj) 11 | if (siblings === undefined) return 12 | const t = siblings[index] 13 | if (index === 0) { 14 | siblings[index] = siblings[siblings.length - 1] 15 | siblings[siblings.length - 1] = t 16 | } else { 17 | siblings[index] = siblings[index - 1] 18 | siblings[index - 1] = t 19 | } 20 | } 21 | 22 | export function moveDownObj(obj: NodeObj) { 23 | const { siblings, index } = getSibling(obj) 24 | if (siblings === undefined) return 25 | const t = siblings[index] 26 | if (index === siblings.length - 1) { 27 | siblings[index] = siblings[0] 28 | siblings[0] = t 29 | } else { 30 | siblings[index] = siblings[index + 1] 31 | siblings[index + 1] = t 32 | } 33 | } 34 | 35 | export function removeNodeObj(obj: NodeObj) { 36 | const { siblings, index } = getSibling(obj) 37 | if (siblings === undefined) return 0 38 | siblings.splice(index, 1) 39 | return siblings.length 40 | } 41 | 42 | export function insertNodeObj(newObj: NodeObj, type: 'before' | 'after', obj: NodeObj) { 43 | const { siblings, index } = getSibling(obj) 44 | if (siblings === undefined) return 45 | if (type === 'before') { 46 | siblings.splice(index, 0, newObj) 47 | } else { 48 | siblings.splice(index + 1, 0, newObj) 49 | } 50 | } 51 | 52 | export function insertParentNodeObj(obj: NodeObj, newObj: NodeObj) { 53 | const { siblings, index } = getSibling(obj) 54 | if (siblings === undefined) return 55 | siblings[index] = newObj 56 | newObj.children = [obj] 57 | } 58 | 59 | export function moveNodeObj(type: 'in' | 'before' | 'after', from: NodeObj, to: NodeObj) { 60 | removeNodeObj(from) 61 | if (!to.parent?.parent) { 62 | from.direction = to.direction 63 | } 64 | if (type === 'in') { 65 | if (to.children) to.children.push(from) 66 | else to.children = [from] 67 | } else { 68 | if (from.direction !== undefined) from.direction = to.direction 69 | const { siblings, index } = getSibling(to) 70 | if (siblings === undefined) return 71 | if (type === 'before') { 72 | siblings.splice(index, 0, from) 73 | } else { 74 | siblings.splice(index + 1, 0, from) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/topic-select.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test' 2 | import { test, expect } from './mind-elixir-test' 3 | 4 | const data = { 5 | nodeData: { 6 | topic: 'root', 7 | id: 'root', 8 | children: [ 9 | { 10 | id: 'middle1', 11 | topic: 'middle1', 12 | children: [ 13 | { 14 | id: 'child1', 15 | topic: 'child1', 16 | }, 17 | { 18 | id: 'child2', 19 | topic: 'child2', 20 | }, 21 | ], 22 | }, 23 | { 24 | id: 'middle2', 25 | topic: 'middle2', 26 | children: [ 27 | { 28 | id: 'child3', 29 | topic: 'child3', 30 | }, 31 | { 32 | id: 'child4', 33 | topic: 'child4', 34 | }, 35 | ], 36 | }, 37 | ], 38 | }, 39 | } 40 | 41 | const select = async (page: Page) => { 42 | await page.mouse.move(200, 100) 43 | await page.mouse.down() 44 | await page.getByText('child2').hover({ force: true }) 45 | await page.mouse.up() 46 | } 47 | 48 | test.beforeEach(async ({ me }) => { 49 | await me.init(data) 50 | }) 51 | 52 | test('Select Sibling', async ({ page, me }) => { 53 | await me.click('child2') 54 | await page.keyboard.press('ArrowUp') 55 | await expect(page.locator('.selected')).toHaveText('child1') 56 | await page.keyboard.press('ArrowDown') 57 | await expect(page.locator('.selected')).toHaveText('child2') 58 | 59 | await select(page) 60 | await page.keyboard.press('ArrowDown') 61 | await expect(page.locator('.selected')).toHaveText('child2') 62 | await page.keyboard.press('ArrowUp') 63 | await expect(page.locator('.selected')).toHaveText('child1') 64 | }) 65 | 66 | test('Parent Child', async ({ page, me }) => { 67 | await me.click('child1') 68 | await page.keyboard.press('ArrowRight') 69 | await expect(page.locator('.selected')).toHaveText('middle1') 70 | await page.keyboard.press('ArrowRight') 71 | await expect(page.locator('.selected')).toHaveText('root') 72 | await page.keyboard.press('ArrowRight') 73 | await expect(page.locator('.selected')).toHaveText('middle2') 74 | await page.keyboard.press('ArrowRight') 75 | await expect(page.locator('.selected')).toHaveText('child3') 76 | 77 | await page.keyboard.press('ArrowLeft') 78 | await expect(page.locator('.selected')).toHaveText('middle2') 79 | await page.keyboard.press('ArrowLeft') 80 | await expect(page.locator('.selected')).toHaveText('root') 81 | await page.keyboard.press('ArrowLeft') 82 | await expect(page.locator('.selected')).toHaveText('middle1') 83 | await page.keyboard.press('ArrowLeft') 84 | await expect(page.locator('.selected')).toHaveText('child1') 85 | }) 86 | -------------------------------------------------------------------------------- /src/viselect/src/types.ts: -------------------------------------------------------------------------------- 1 | import type SelectionArea from './index' 2 | import type { Intersection } from './utils/intersects' 3 | import type { Trigger } from './utils/matchesTrigger' 4 | 5 | export type DeepPartial = T extends unknown[] ? T : T extends HTMLElement ? T : { [P in keyof T]?: DeepPartial } 6 | 7 | export type Quantify = T[] | T 8 | 9 | export interface ScrollEvent extends MouseEvent { 10 | deltaY: number 11 | deltaX: number 12 | } 13 | 14 | export interface ChangedElements { 15 | added: Element[] 16 | removed: Element[] 17 | } 18 | 19 | export interface SelectionStore { 20 | touched: Element[] 21 | stored: Element[] 22 | selected: Element[] 23 | changed: ChangedElements 24 | } 25 | 26 | export interface SelectionEvent { 27 | event: MouseEvent | TouchEvent | null 28 | store: SelectionStore 29 | selection: SelectionArea 30 | } 31 | 32 | export type SelectionEvents = { 33 | beforestart: (e: SelectionEvent) => boolean | void 34 | beforedrag: (e: SelectionEvent) => boolean | void 35 | start: (e: SelectionEvent) => void 36 | move: (e: SelectionEvent) => void 37 | stop: (e: SelectionEvent) => void 38 | } 39 | 40 | export type AreaLocation = { 41 | x1: number 42 | y1: number 43 | x2: number 44 | y2: number 45 | } 46 | 47 | export interface Coordinates { 48 | x: number 49 | y: number 50 | } 51 | 52 | export type TapMode = 'touch' | 'native' 53 | export type OverlapMode = 'keep' | 'drop' | 'invert' 54 | 55 | export interface Scrolling { 56 | speedDivider: number 57 | manualSpeed: number 58 | startScrollMargins: { x: number; y: number } 59 | } 60 | 61 | export interface SingleTap { 62 | allow: boolean 63 | intersect: TapMode 64 | } 65 | 66 | export interface Features { 67 | deselectOnBlur: boolean 68 | singleTap: SingleTap 69 | range: boolean 70 | touch: boolean 71 | } 72 | 73 | export interface Behaviour { 74 | intersect: Intersection 75 | startThreshold: number | Coordinates 76 | overlap: OverlapMode 77 | scrolling: Scrolling 78 | triggers: Trigger[] 79 | } 80 | 81 | export interface SelectionOptions { 82 | selectionAreaClass: string 83 | selectionContainerClass: string | undefined 84 | container: Quantify 85 | 86 | document: Document 87 | selectables: Quantify 88 | 89 | startAreas: Quantify 90 | boundaries: Quantify 91 | 92 | behaviour: Behaviour 93 | features: Features 94 | mindElixirInstance?: any // MindElixir instance for custom scrolling 95 | } 96 | 97 | export type PartialSelectionOptions = DeepPartial> & { 98 | document?: Document 99 | } 100 | -------------------------------------------------------------------------------- /tests/multiple-node.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test' 2 | import { test, expect } from './mind-elixir-test' 3 | 4 | const data = { 5 | nodeData: { 6 | topic: 'root', 7 | id: 'root', 8 | children: [ 9 | { 10 | id: 'middle1', 11 | topic: 'middle1', 12 | children: [ 13 | { 14 | id: 'child1', 15 | topic: 'child1', 16 | }, 17 | { 18 | id: 'child2', 19 | topic: 'child2', 20 | }, 21 | ], 22 | }, 23 | { 24 | id: 'middle2', 25 | topic: 'middle2', 26 | children: [ 27 | { 28 | id: 'child3', 29 | topic: 'child3', 30 | }, 31 | { 32 | id: 'child4', 33 | topic: 'child4', 34 | }, 35 | ], 36 | }, 37 | ], 38 | }, 39 | } 40 | 41 | test.beforeEach(async ({ me }) => { 42 | await me.init(data) 43 | }) 44 | 45 | const select = async (page: Page) => { 46 | await page.mouse.move(200, 100) 47 | await page.mouse.down() 48 | await page.getByText('child2').hover({ force: true }) 49 | await page.mouse.up() 50 | } 51 | 52 | test('Multiple seletion', async ({ page }) => { 53 | await select(page) 54 | await expect(page.locator('.selected').filter({ hasText: 'child1' })).toBeVisible() 55 | await expect(page.locator('.selected').filter({ hasText: 'child2' })).toBeVisible() 56 | }) 57 | 58 | test('Multiple Move Before', async ({ page, me }) => { 59 | await select(page) 60 | await page.getByText('child1').hover({ force: true }) 61 | await page.mouse.down() 62 | await me.dragOver('child3', 'before') 63 | await expect(page.locator('.insert-preview.before')).toBeVisible() 64 | await page.mouse.up() 65 | await me.toHaveScreenshot() 66 | }) 67 | 68 | test('Multiple Move After', async ({ page, me }) => { 69 | await select(page) 70 | await page.getByText('child1').hover({ force: true }) 71 | await page.mouse.down() 72 | await me.dragOver('child3', 'after') 73 | await expect(page.locator('.insert-preview.after')).toBeVisible() 74 | await page.mouse.up() 75 | await me.toHaveScreenshot() 76 | }) 77 | 78 | test('Multiple Move In', async ({ page, me }) => { 79 | await select(page) 80 | await page.getByText('child1').hover({ force: true }) 81 | await page.mouse.down() 82 | await me.dragOver('child3', 'in') 83 | await expect(page.locator('.insert-preview.in')).toBeVisible() 84 | await page.mouse.up() 85 | await me.toHaveScreenshot() 86 | }) 87 | 88 | test('Multiple Copy', async ({ page, me }) => { 89 | await select(page) 90 | await page.keyboard.press('Control+c') 91 | await me.click('child3') 92 | await page.keyboard.press('Control+v') 93 | await me.toHaveScreenshot() 94 | }) 95 | -------------------------------------------------------------------------------- /src/utils/layout.ts: -------------------------------------------------------------------------------- 1 | import { LEFT, RIGHT, SIDE } from '../const' 2 | import type { Children } from '../types/dom' 3 | import { DirectionClass, type MindElixirInstance, type NodeObj } from '../types/index' 4 | import { shapeTpc } from './dom' 5 | 6 | const $d = document 7 | 8 | // Set main nodes' direction and invoke layoutChildren() 9 | export const layout = function (this: MindElixirInstance) { 10 | console.time('layout') 11 | this.nodes.innerHTML = '' 12 | 13 | const tpc = this.createTopic(this.nodeData) 14 | shapeTpc.call(this, tpc, this.nodeData) // shape root tpc 15 | tpc.draggable = false 16 | const root = $d.createElement('me-root') 17 | root.appendChild(tpc) 18 | 19 | const mainNodes = this.nodeData.children || [] 20 | if (this.direction === SIDE) { 21 | // initiate direction of main nodes 22 | let lcount = 0 23 | let rcount = 0 24 | mainNodes.map(node => { 25 | if (node.direction === LEFT) { 26 | lcount += 1 27 | } else if (node.direction === RIGHT) { 28 | rcount += 1 29 | } else { 30 | if (lcount <= rcount) { 31 | node.direction = LEFT 32 | lcount += 1 33 | } else { 34 | node.direction = RIGHT 35 | rcount += 1 36 | } 37 | } 38 | }) 39 | } 40 | layoutMainNode(this, mainNodes, root) 41 | console.timeEnd('layout') 42 | } 43 | 44 | const layoutMainNode = function (mei: MindElixirInstance, data: NodeObj[], root: HTMLElement) { 45 | const leftPart = $d.createElement('me-main') 46 | leftPart.className = DirectionClass.LHS 47 | const rightPart = $d.createElement('me-main') 48 | rightPart.className = DirectionClass.RHS 49 | for (let i = 0; i < data.length; i++) { 50 | const nodeObj = data[i] 51 | const { grp: w } = mei.createWrapper(nodeObj) 52 | if (mei.direction === SIDE) { 53 | if (nodeObj.direction === LEFT) { 54 | leftPart.appendChild(w) 55 | } else { 56 | rightPart.appendChild(w) 57 | } 58 | } else if (mei.direction === LEFT) { 59 | leftPart.appendChild(w) 60 | } else { 61 | rightPart.appendChild(w) 62 | } 63 | } 64 | 65 | mei.nodes.appendChild(leftPart) 66 | mei.nodes.appendChild(root) 67 | mei.nodes.appendChild(rightPart) 68 | 69 | mei.nodes.appendChild(mei.lines) 70 | mei.nodes.appendChild(mei.labelContainer) 71 | } 72 | 73 | export const layoutChildren = function (mei: MindElixirInstance, data: NodeObj[]) { 74 | const chldr = $d.createElement('me-children') as Children 75 | for (let i = 0; i < data.length; i++) { 76 | const nodeObj = data[i] 77 | const { grp } = mei.createWrapper(nodeObj) 78 | chldr.appendChild(grp) 79 | } 80 | return chldr 81 | } 82 | -------------------------------------------------------------------------------- /src/plugin/toolBar.ts: -------------------------------------------------------------------------------- 1 | import type { MindElixirInstance } from '../types/index' 2 | import side from '../icons/side.svg?raw' 3 | import left from '../icons/left.svg?raw' 4 | import right from '../icons/right.svg?raw' 5 | import full from '../icons/full.svg?raw' 6 | import living from '../icons/living.svg?raw' 7 | import zoomin from '../icons/zoomin.svg?raw' 8 | import zoomout from '../icons/zoomout.svg?raw' 9 | 10 | import './toolBar.less' 11 | 12 | const map: Record = { 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 = `
` 2 | 3 | export const codeBlock = `
let message = 'Hello world'
4 | alert(message)
` 5 | 6 | export const styledDiv = `
Title
Hello world
` 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 | --------------------------------------------------------------------------------