commit 473102fc1dfcf2b1f092188d3ca7c5df7874b879 Author: pikaqiu Date: Sat May 16 21:34:04 2026 +0800 Initial OneMind Obsidian plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93369e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.DS_Store +*.tgz +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm/ +.cache/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5af379 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# OneMind + +OneMind is an editor-first Obsidian mind map plugin. It opens the current Markdown note as a clean SVG + DOM mind map and writes edits back to plain Markdown. + +## MVP Features + +- Open the active Markdown note in a full-page OneMind view. +- Switch from Markdown to OneMind and focus the node matching the current heading. +- Copy a selected node as a standard `obsidian://open` URI with a heading anchor. +- Parse headings and unordered lists into a tree. +- Render a horizontal mind map with SVG Bezier links and DOM nodes. +- Choose between right-facing and balanced two-sided layouts. +- Add child nodes with `Tab`, add sibling nodes with `Enter`. +- Duplicate nodes and restructure with `Alt/Option + Arrow keys`. +- Use the node context menu for common edit, copy, tag, export, and delete actions. +- Long-press nodes on touch devices to open the same context menu. +- Edit nodes with `F2` or double click. +- Add, replace, or clear node emoji icons; icons are stored as leading emoji in Markdown text. +- Show Markdown tags such as `#todo` or `#idea` as node badges. +- Add or clear Markdown tag badges from selected nodes, including multi-selections. +- Filter the visible mind map by tag from tag badges or the command palette. +- See visible/total node counts, selected count, zoom, filter, and save state in the canvas status bar. +- Copy a selected branch as Markdown and paste Markdown from the clipboard as child or sibling nodes. +- Delete nodes with `Delete` or `Backspace`. +- Multi-select nodes with `Ctrl/Cmd + click`, range-select with `Shift + click`, and select all with `Ctrl/Cmd + A`. +- Apply delete, collapse, and emoji actions to the current multi-selection. +- Drag nodes onto another node to make them children, or between nearby nodes to reorder siblings. +- Collapse or expand nodes with `Space`. +- Expand or collapse the full map from toolbar actions or commands. +- Search nodes with `/`, then use `Enter` and `Shift + Enter` to move through matches. +- Navigate large maps with the collapsible outline panel. +- Move selection with arrow keys. +- Pan with blank-canvas drag. +- Zoom with mouse wheel or `Ctrl/Cmd +` and `Ctrl/Cmd -`. +- Fit to view with `Ctrl/Cmd + Shift + F`, focus selection with `Ctrl/Cmd + F`. +- Basic local undo and redo with `Ctrl/Cmd + Z` and `Ctrl/Cmd + Shift + Z`. +- Inline rendering for `**bold**`, `` `code` ``, and `[[wikilinks]]`. +- Draw dashed cross-branch association links from local wikilinks like `[[#Target]]` or `[[Target]]`. +- Export the current mind map as `.onemind.svg` or `.onemind.png` beside the source note. +- Export only the selected branch as SVG or PNG from commands. +- Configure PNG export scale and transparent background. +- Preserve frontmatter and leading non-map Markdown when writing edits back. +- Resolve external Markdown changes with a conflict prompt when local mind map edits are still unsaved. +- Responsive toolbar treatment for narrow/mobile panes. +- Chinese and English UI language setting. +- Settings for layout direction, auto-save delay, default expand depth, animation, visual branch color pickers, and export behavior. +- Obsidian commands for node editing actions, ready for custom hotkeys. + +## Development + +```bash +npm install +npm run dev +``` + +For a production build: + +```bash +npm run build +``` + +Copy or symlink this folder into an Obsidian vault under `.obsidian/plugins/onemind`, then enable the plugin from Obsidian settings. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..1ccb124 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,44 @@ +import esbuild from "esbuild"; +import process from "node:process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + banner: { + js: "/* OneMind - Obsidian mind map plugin */" + }, + entryPoints: ["main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod +}); + +if (prod) { + await context.rebuild(); + await context.dispose(); +} else { + await context.watch(); + console.log("Watching for changes..."); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..08e1f5e --- /dev/null +++ b/main.js @@ -0,0 +1,13 @@ +/* OneMind - Obsidian mind map plugin */ +var K=Object.defineProperty;var we=Object.getOwnPropertyDescriptor;var xe=Object.getOwnPropertyNames;var ye=Object.prototype.hasOwnProperty;var Se=(o,s)=>{for(var e in s)K(o,e,{get:s[e],enumerable:!0})},Me=(o,s,e,t)=>{if(s&&typeof s=="object"||typeof s=="function")for(let i of xe(s))!ye.call(o,i)&&i!==e&&K(o,i,{get:()=>s[i],enumerable:!(t=we(s,i))||t.enumerable});return o};var be=o=>Me(K({},"__esModule",{value:!0}),o);var Fe={};Se(Fe,{default:()=>V});module.exports=be(Fe);var h=require("obsidian"),L="onemind-view",te=["\u{1F4A1}","\u2705","\u{1F525}","\u2B50","\u{1F3AF}","\u{1F680}","\u{1F4CC}","\u{1F4CE}","\u{1F9E0}","\u{1F4DD}","\u{1F4DA}","\u{1F50D}","\u26A1","\u2764\uFE0F","\u2753","\u26A0\uFE0F","\u{1F331}","\u{1F3C1}","\u{1F527}","\u{1F4AC}","\u{1F3A8}","\u{1F9E9}","\u{1F4CA}","\u{1F517}"],$={autoSaveDelay:300,branchColors:["#5b8def","#45b36b","#e89b3c","#9b72e7","#df6f9f"],defaultExpandDepth:99,animations:!0,layoutDirection:"right",exportScale:2,exportTransparentBackground:!1,language:"zh",showAssociationLinks:!0},ie={en:{openRibbon:"Open OneMind",openMindMap:"Open current note as mind map",returnMarkdown:"Return to Markdown editor",addChild:"Add child node",addSibling:"Add sibling node",duplicateNode:"Duplicate selected node",moveUp:"Move selected node up",moveDown:"Move selected node down",indentNode:"Indent selected node",outdentNode:"Outdent selected node",editNode:"Edit selected node",deleteNode:"Delete selected node",toggleNode:"Toggle selected node",setEmoji:"Set selected node emoji",clearEmoji:"Clear selected node emoji",addTag:"Add tag to selected nodes",clearTags:"Clear tags from selected nodes",copyBranch:"Copy selected branch as Markdown",pasteChild:"Paste Markdown as child nodes",pasteSibling:"Paste Markdown as sibling nodes",selectAll:"Select all nodes",expandAll:"Expand all nodes",collapseAll:"Collapse all nodes",fitMap:"Fit mind map to view",focusNode:"Focus selected node",searchNodes:"Search nodes",filterByTag:"Filter by tag",clearTagFilter:"Clear tag filter",toggleOutline:"Toggle outline",copyNodeLink:"Copy selected node link",exportSvg:"Export mind map as SVG",exportPng:"Export mind map as PNG",exportSelectedSvg:"Export selected branch as SVG",exportSelectedPng:"Export selected branch as PNG",markdown:"Markdown",addChildShort:"Add child",addSiblingShort:"Add sibling",duplicateShort:"Duplicate",setEmojiIcon:"Set emoji icon",addTagShort:"Add tag",deleteShort:"Delete",fitShort:"Fit",exportSvgShort:"Export SVG",exportPngShort:"Export PNG",copyLinkShort:"Copy node link",outline:"Outline",hideOutline:"Hide outline",openMarkdownFirst:"Open a Markdown note first.",emptyView:"Open a Markdown note to use OneMind.",reloadedExternal:"OneMind reloaded the external Markdown changes.",keptLocal:"OneMind kept the mind map edits and wrote them to Markdown.",exported:"Exported",copiedLink:"Copied OneMind node link.",copiedBranch:"Copied branch Markdown.",pastedMarkdown:"Pasted Markdown into OneMind.",clipboardEmpty:"Clipboard does not contain Markdown nodes.",tagFilterActive:"Tag filter",chooseEmoji:"Choose an emoji icon",enterTag:"Enter tag name",conflictTitle:"OneMind conflict",conflictBody:"This note changed on disk while the mind map has unsaved local edits. Choose which version should win.",reloadMarkdown:"Reload Markdown",keepEdits:"Keep OneMind edits",settingsTitle:"OneMind",language:"Language",languageDesc:"Choose the language used by OneMind UI.",english:"English",chinese:"\u4E2D\u6587",autoSaveDelay:"Auto-save delay",autoSaveDelayDesc:"Milliseconds to wait after a mind map edit before writing Markdown.",defaultExpandDepth:"Default expand depth",defaultExpandDepthDesc:"How many levels are expanded when a note opens. Use 99 for all.",layoutDirection:"Layout direction",layoutDirectionDesc:"Choose a classic right-facing tree or a balanced map with branches on both sides.",horizontalRight:"Horizontal right",balanced:"Balanced",layoutAnimation:"Layout animation",layoutAnimationDesc:"Animate node movement after edits.",branchColors:"Branch colors",branchColorsDesc:"Comma-separated CSS colors used for first-level branches.",resetBranchColors:"Reset branch colors",pngScale:"PNG export scale",pngScaleDesc:"Resolution multiplier used when exporting PNG files.",transparentPng:"Transparent PNG background",transparentPngDesc:"Leave PNG exports transparent instead of filling the Obsidian background color.",showAssociationLinks:"Show association links",showAssociationLinksDesc:"Draw dashed cross-branch links for local wikilinks like [[#Heading]] or [[Heading]].",saved:"Saved",unsaved:"Unsaved",saving:"Saving",nodesLabel:"nodes",selectedLabel:"selected",zoomLabel:"zoom"},zh:{openRibbon:"\u6253\u5F00 OneMind",openMindMap:"\u4EE5\u601D\u7EF4\u5BFC\u56FE\u6253\u5F00\u5F53\u524D\u7B14\u8BB0",returnMarkdown:"\u8FD4\u56DE Markdown \u7F16\u8F91\u5668",addChild:"\u6DFB\u52A0\u5B50\u8282\u70B9",addSibling:"\u6DFB\u52A0\u540C\u7EA7\u8282\u70B9",duplicateNode:"\u590D\u5236\u9009\u4E2D\u8282\u70B9",moveUp:"\u4E0A\u79FB\u9009\u4E2D\u8282\u70B9",moveDown:"\u4E0B\u79FB\u9009\u4E2D\u8282\u70B9",indentNode:"\u7F29\u8FDB\u9009\u4E2D\u8282\u70B9",outdentNode:"\u53D6\u6D88\u7F29\u8FDB\u9009\u4E2D\u8282\u70B9",editNode:"\u7F16\u8F91\u9009\u4E2D\u8282\u70B9",deleteNode:"\u5220\u9664\u9009\u4E2D\u8282\u70B9",toggleNode:"\u6298\u53E0/\u5C55\u5F00\u9009\u4E2D\u8282\u70B9",setEmoji:"\u8BBE\u7F6E\u9009\u4E2D\u8282\u70B9 Emoji",clearEmoji:"\u6E05\u9664\u9009\u4E2D\u8282\u70B9 Emoji",addTag:"\u7ED9\u9009\u4E2D\u8282\u70B9\u6DFB\u52A0\u6807\u7B7E",clearTags:"\u6E05\u9664\u9009\u4E2D\u8282\u70B9\u6807\u7B7E",copyBranch:"\u590D\u5236\u9009\u4E2D\u5206\u652F\u4E3A Markdown",pasteChild:"\u7C98\u8D34 Markdown \u4E3A\u5B50\u8282\u70B9",pasteSibling:"\u7C98\u8D34 Markdown \u4E3A\u540C\u7EA7\u8282\u70B9",selectAll:"\u9009\u62E9\u5168\u90E8\u8282\u70B9",expandAll:"\u5C55\u5F00\u5168\u90E8\u8282\u70B9",collapseAll:"\u6298\u53E0\u5168\u90E8\u8282\u70B9",fitMap:"\u9002\u5E94\u7A97\u53E3",focusNode:"\u805A\u7126\u9009\u4E2D\u8282\u70B9",searchNodes:"\u641C\u7D22\u8282\u70B9",filterByTag:"\u6309\u6807\u7B7E\u8FC7\u6EE4",clearTagFilter:"\u6E05\u9664\u6807\u7B7E\u8FC7\u6EE4",toggleOutline:"\u663E\u793A/\u9690\u85CF\u5927\u7EB2",copyNodeLink:"\u590D\u5236\u9009\u4E2D\u8282\u70B9\u94FE\u63A5",exportSvg:"\u5BFC\u51FA\u601D\u7EF4\u5BFC\u56FE\u4E3A SVG",exportPng:"\u5BFC\u51FA\u601D\u7EF4\u5BFC\u56FE\u4E3A PNG",exportSelectedSvg:"\u5BFC\u51FA\u9009\u4E2D\u5206\u652F\u4E3A SVG",exportSelectedPng:"\u5BFC\u51FA\u9009\u4E2D\u5206\u652F\u4E3A PNG",markdown:"Markdown",addChildShort:"\u6DFB\u52A0\u5B50\u8282\u70B9",addSiblingShort:"\u6DFB\u52A0\u540C\u7EA7",duplicateShort:"\u590D\u5236\u8282\u70B9",setEmojiIcon:"\u8BBE\u7F6E Emoji \u56FE\u6807",addTagShort:"\u6DFB\u52A0\u6807\u7B7E",deleteShort:"\u5220\u9664",fitShort:"\u9002\u5E94\u7A97\u53E3",exportSvgShort:"\u5BFC\u51FA SVG",exportPngShort:"\u5BFC\u51FA PNG",copyLinkShort:"\u590D\u5236\u8282\u70B9\u94FE\u63A5",outline:"\u5927\u7EB2",hideOutline:"\u9690\u85CF\u5927\u7EB2",openMarkdownFirst:"\u8BF7\u5148\u6253\u5F00\u4E00\u7BC7 Markdown \u7B14\u8BB0\u3002",emptyView:"\u6253\u5F00 Markdown \u7B14\u8BB0\u540E\u5373\u53EF\u4F7F\u7528 OneMind\u3002",reloadedExternal:"OneMind \u5DF2\u91CD\u65B0\u52A0\u8F7D\u5916\u90E8 Markdown \u4FEE\u6539\u3002",keptLocal:"OneMind \u5DF2\u4FDD\u7559\u5BFC\u56FE\u7F16\u8F91\u5E76\u5199\u56DE Markdown\u3002",exported:"\u5DF2\u5BFC\u51FA",copiedLink:"\u5DF2\u590D\u5236 OneMind \u8282\u70B9\u94FE\u63A5\u3002",copiedBranch:"\u5DF2\u590D\u5236\u5206\u652F Markdown\u3002",pastedMarkdown:"\u5DF2\u5C06 Markdown \u7C98\u8D34\u5230 OneMind\u3002",clipboardEmpty:"\u526A\u8D34\u677F\u91CC\u6CA1\u6709\u53EF\u7528\u7684 Markdown \u8282\u70B9\u3002",tagFilterActive:"\u6807\u7B7E\u8FC7\u6EE4",chooseEmoji:"\u9009\u62E9 Emoji \u56FE\u6807",enterTag:"\u8F93\u5165\u6807\u7B7E\u540D",conflictTitle:"OneMind \u51B2\u7A81",conflictBody:"\u8FD9\u7BC7\u7B14\u8BB0\u5728\u78C1\u76D8\u4E0A\u53D1\u751F\u4E86\u53D8\u5316\uFF0C\u540C\u65F6\u601D\u7EF4\u5BFC\u56FE\u91CC\u8FD8\u6709\u672A\u4FDD\u5B58\u7F16\u8F91\u3002\u8BF7\u9009\u62E9\u8981\u4FDD\u7559\u7684\u7248\u672C\u3002",reloadMarkdown:"\u91CD\u65B0\u52A0\u8F7D Markdown",keepEdits:"\u4FDD\u7559 OneMind \u7F16\u8F91",settingsTitle:"OneMind",language:"\u8BED\u8A00",languageDesc:"\u9009\u62E9 OneMind \u754C\u9762\u8BED\u8A00\u3002",english:"English",chinese:"\u4E2D\u6587",autoSaveDelay:"\u81EA\u52A8\u4FDD\u5B58\u5EF6\u8FDF",autoSaveDelayDesc:"\u5BFC\u56FE\u7F16\u8F91\u540E\u7B49\u5F85\u591A\u5C11\u6BEB\u79D2\u518D\u5199\u56DE Markdown\u3002",defaultExpandDepth:"\u9ED8\u8BA4\u5C55\u5F00\u6DF1\u5EA6",defaultExpandDepthDesc:"\u6253\u5F00\u7B14\u8BB0\u65F6\u9ED8\u8BA4\u5C55\u5F00\u7684\u5C42\u7EA7\u300299 \u8868\u793A\u5168\u90E8\u5C55\u5F00\u3002",layoutDirection:"\u5E03\u5C40\u65B9\u5411",layoutDirectionDesc:"\u9009\u62E9\u7ECF\u5178\u5411\u53F3\u6811\u5F62\u5E03\u5C40\uFF0C\u6216\u6839\u8282\u70B9\u5C45\u4E2D\u7684\u53CC\u5411\u5E73\u8861\u5E03\u5C40\u3002",horizontalRight:"\u6C34\u5E73\u5411\u53F3",balanced:"\u53CC\u5411\u5E73\u8861",layoutAnimation:"\u5E03\u5C40\u52A8\u753B",layoutAnimationDesc:"\u7F16\u8F91\u540E\u4E3A\u8282\u70B9\u79FB\u52A8\u6DFB\u52A0\u52A8\u753B\u3002",branchColors:"\u5206\u652F\u989C\u8272",branchColorsDesc:"\u4E00\u7EA7\u5206\u652F\u4F7F\u7528\u7684 CSS \u989C\u8272\uFF0C\u4F7F\u7528\u82F1\u6587\u9017\u53F7\u5206\u9694\u3002",resetBranchColors:"\u91CD\u7F6E\u5206\u652F\u989C\u8272",pngScale:"PNG \u5BFC\u51FA\u500D\u7387",pngScaleDesc:"\u5BFC\u51FA PNG \u6587\u4EF6\u65F6\u4F7F\u7528\u7684\u5206\u8FA8\u7387\u500D\u7387\u3002",transparentPng:"\u900F\u660E PNG \u80CC\u666F",transparentPngDesc:"\u5BFC\u51FA PNG \u65F6\u4FDD\u7559\u900F\u660E\u80CC\u666F\uFF0C\u800C\u4E0D\u662F\u586B\u5145 Obsidian \u80CC\u666F\u8272\u3002",showAssociationLinks:"\u663E\u793A\u5173\u8054\u7EBF",showAssociationLinksDesc:"\u4E3A [[#\u6807\u9898]] \u6216 [[\u6807\u9898]] \u8FD9\u7C7B\u672C\u5730 wikilink \u7ED8\u5236\u8DE8\u5206\u652F\u865A\u7EBF\u5173\u8054\u3002",saved:"\u5DF2\u4FDD\u5B58",unsaved:"\u672A\u4FDD\u5B58",saving:"\u4FDD\u5B58\u4E2D",nodesLabel:"\u8282\u70B9",selectedLabel:"\u5DF2\u9009",zoomLabel:"\u7F29\u653E"}},ke=180,S=42,X=230,C=18,Ee=4,V=class extends h.Plugin{constructor(){super(...arguments);this.settings=$}async onload(){this.settings=Object.assign({},$,await this.loadData()),this.registerView(L,e=>new T(e,this)),this.addRibbonIcon("git-fork",this.t("openRibbon"),()=>{this.openMindMap()}),this.addCommand({id:"open-onemind-view",name:this.t("openMindMap"),checkCallback:e=>{let t=this.getActiveMarkdownFile();return t?(e||this.openMindMap(t,this.getActiveHeadingAnchor()),!0):!1}}),this.addCommand({id:"return-to-markdown",name:this.t("returnMarkdown"),callback:()=>{let e=this.app.workspace.getActiveViewOfType(T);e&&e.openMarkdown()}}),this.addMindMapCommand("add-child-node",this.t("addChild"),e=>e.addChildNode()),this.addMindMapCommand("add-sibling-node",this.t("addSibling"),e=>e.addSiblingNode()),this.addMindMapCommand("duplicate-node",this.t("duplicateNode"),e=>e.duplicateSelectedNode()),this.addMindMapCommand("move-node-up",this.t("moveUp"),e=>e.moveSelectedNode("up")),this.addMindMapCommand("move-node-down",this.t("moveDown"),e=>e.moveSelectedNode("down")),this.addMindMapCommand("indent-node",this.t("indentNode"),e=>e.moveSelectedNode("indent")),this.addMindMapCommand("outdent-node",this.t("outdentNode"),e=>e.moveSelectedNode("outdent")),this.addMindMapCommand("edit-selected-node",this.t("editNode"),e=>e.editSelectedNode()),this.addMindMapCommand("delete-selected-node",this.t("deleteNode"),e=>e.deleteSelectedNode()),this.addMindMapCommand("toggle-selected-node",this.t("toggleNode"),e=>e.toggleSelectedNode()),this.addMindMapCommand("set-node-emoji",this.t("setEmoji"),e=>e.openEmojiPicker()),this.addMindMapCommand("clear-node-emoji",this.t("clearEmoji"),e=>e.clearSelectedEmoji()),this.addMindMapCommand("add-node-tag",this.t("addTag"),e=>e.openTagModal()),this.addMindMapCommand("clear-node-tags",this.t("clearTags"),e=>e.clearSelectedTags()),this.addMindMapCommand("select-all-nodes",this.t("selectAll"),e=>e.selectAllNodes()),this.addMindMapCommand("expand-all-nodes",this.t("expandAll"),e=>e.expandAllNodes()),this.addMindMapCommand("collapse-all-nodes",this.t("collapseAll"),e=>e.collapseAllNodes()),this.addMindMapCommand("fit-mind-map",this.t("fitMap"),e=>e.fitToView()),this.addMindMapCommand("focus-selected-node",this.t("focusNode"),e=>e.focusSelected()),this.addMindMapCommand("search-nodes",this.t("searchNodes"),e=>e.focusSearch()),this.addMindMapCommand("filter-by-tag",this.t("filterByTag"),e=>e.openTagFilterModal()),this.addMindMapCommand("clear-tag-filter",this.t("clearTagFilter"),e=>e.clearTagFilter()),this.addMindMapCommand("toggle-outline",this.t("toggleOutline"),e=>e.toggleOutline()),this.addMindMapCommand("copy-node-link",this.t("copyNodeLink"),e=>void e.copySelectedNodeLink()),this.addMindMapCommand("copy-branch-markdown",this.t("copyBranch"),e=>void e.copySelectedBranchMarkdown()),this.addMindMapCommand("paste-markdown-child",this.t("pasteChild"),e=>void e.pasteMarkdownAsChild()),this.addMindMapCommand("paste-markdown-sibling",this.t("pasteSibling"),e=>void e.pasteMarkdownAsSibling()),this.addMindMapCommand("export-svg",this.t("exportSvg"),e=>void e.exportSvg()),this.addMindMapCommand("export-png",this.t("exportPng"),e=>void e.exportPng()),this.addMindMapCommand("export-selected-svg",this.t("exportSelectedSvg"),e=>void e.exportSelectedSvg()),this.addMindMapCommand("export-selected-png",this.t("exportSelectedPng"),e=>void e.exportSelectedPng()),this.addSettingTab(new G(this.app,this))}onunload(){this.app.workspace.detachLeavesOfType(L)}async saveSettings(){await this.saveData(this.settings)}t(e){var t,i;return(i=(t=ie[this.settings.language])==null?void 0:t[e])!=null?i:ie.en[e]}refreshOpenViews(){this.app.workspace.getLeavesOfType(L).forEach(e=>{let t=e.view;t instanceof T&&t.applySettings()})}addMindMapCommand(e,t,i){this.addCommand({id:e,name:t,checkCallback:n=>{let r=this.app.workspace.getActiveViewOfType(T);return r?(n||i(r),!0):!1}})}getActiveMarkdownFile(){var t;let e=this.app.workspace.getActiveViewOfType(h.MarkdownView);return(t=e==null?void 0:e.file)!=null?t:null}getActiveHeadingAnchor(){let e=this.app.workspace.getActiveViewOfType(h.MarkdownView);if(!e)return null;let t=e.editor.getCursor();for(let i=t.line;i>=0;i-=1){let n=/^(#{1,6})\s+(.+?)\s*$/.exec(e.editor.getLine(i));if(n)return N(n[2])}return null}async openMindMap(e=this.getActiveMarkdownFile(),t=null){if(!e){new h.Notice(this.t("openMarkdownFirst"));return}await this.app.workspace.getLeaf(!1).setViewState({type:L,state:{file:e.path,heading:t!=null?t:void 0},active:!0})}},T=class extends h.ItemView{constructor(e,t){super(e);this.file=null;this.root=null;this.selectedId=null;this.selectedIds=new Set;this.positions=[];this.searchQuery="";this.searchMatches=[];this.activeSearchIndex=-1;this.saveTimer=null;this.isSaving=!1;this.history=[];this.future=[];this.scale=1;this.panX=80;this.panY=80;this.panning=null;this.dragState=null;this.longPressTimer=null;this.dropTarget=null;this.dragMarkerEl=null;this.lastSavedMarkdown="";this.hasLocalUnsavedChange=!1;this.documentPrefix="";this.conflictModalOpen=!1;this.pendingFocus=null;this.outlineVisible=!0;this.tagFilter=null;this.plugin=t}getViewType(){return L}getDisplayText(){return this.file?`OneMind: ${this.file.basename}`:"OneMind"}getIcon(){return"git-fork"}async setState(e,t){await super.setState(e,t),this.file=e.file?this.app.vault.getAbstractFileByPath(e.file):null,this.pendingFocus={node:e.node,heading:e.heading},await this.loadFile()}getState(){var e,t,i;return{file:(t=(e=this.file)==null?void 0:e.path)!=null?t:"",node:(i=this.selectedId)!=null?i:void 0}}async onOpen(){this.contentEl.empty(),this.contentEl.addClass("onemind-view"),this.contentEl.toggleClass("is-animation-disabled",!this.plugin.settings.animations);let e=this.contentEl.createDiv({cls:"onemind-toolbar"});this.addIconButton(e,"file-text",this.plugin.t("markdown"),()=>void this.openMarkdown()),this.addIconButton(e,"plus",this.plugin.t("addChildShort"),()=>this.addChildNode()),this.addIconButton(e,"corner-down-right",this.plugin.t("addSiblingShort"),()=>this.addSiblingNode()),this.addIconButton(e,"copy-plus",this.plugin.t("duplicateShort"),()=>this.duplicateSelectedNode()),this.addIconButton(e,"smile",this.plugin.t("setEmojiIcon"),()=>this.openEmojiPicker()),this.addIconButton(e,"tag",this.plugin.t("addTagShort"),()=>this.openTagModal()),this.addIconButton(e,"filter",this.plugin.t("filterByTag"),()=>this.openTagFilterModal()),this.addIconButton(e,"trash-2",this.plugin.t("deleteShort"),()=>this.deleteSelectedNode()),this.addIconButton(e,"unfold-vertical",this.plugin.t("expandAll"),()=>this.expandAllNodes()),this.addIconButton(e,"fold-vertical",this.plugin.t("collapseAll"),()=>this.collapseAllNodes()),this.addIconButton(e,"minimize",this.plugin.t("fitShort"),()=>this.fitToView()),this.addIconButton(e,"download",this.plugin.t("exportSvgShort"),()=>void this.exportSvg()),this.addIconButton(e,"image",this.plugin.t("exportPngShort"),()=>void this.exportPng()),this.addIconButton(e,"link",this.plugin.t("copyLinkShort"),()=>void this.copySelectedNodeLink()),this.addIconButton(e,"copy",this.plugin.t("copyBranch"),()=>void this.copySelectedBranchMarkdown()),this.addIconButton(e,"list-tree",this.plugin.t("toggleOutline"),()=>this.toggleOutline()),this.addIconButton(e,"search",this.plugin.t("searchNodes"),()=>this.searchInputEl.focus());let t=e.createDiv({cls:"onemind-search"});this.searchInputEl=t.createEl("input",{cls:"onemind-search-input",attr:{type:"search",placeholder:this.plugin.t("searchNodes"),"aria-label":this.plugin.t("searchNodes")}}),this.searchCountEl=t.createEl("span",{cls:"onemind-search-count"}),this.searchInputEl.addEventListener("input",()=>this.updateSearch(this.searchInputEl.value)),this.searchInputEl.addEventListener("keydown",n=>this.onSearchKeyDown(n)),this.canvasEl=this.contentEl.createDiv({cls:"onemind-canvas"}),this.sceneEl=this.canvasEl.createDiv({cls:"onemind-scene"}),this.svgEl=U("svg"),this.svgEl.addClass("onemind-links"),this.nodesEl=this.sceneEl.createDiv({cls:"onemind-nodes"}),this.sceneEl.prepend(this.svgEl),this.outlineEl=this.canvasEl.createDiv({cls:"onemind-outline"});let i=this.outlineEl.createDiv({cls:"onemind-outline-header"});i.createSpan({text:this.plugin.t("outline")}),this.addIconButton(i,"x",this.plugin.t("hideOutline"),()=>this.toggleOutline(!1)),this.outlineListEl=this.outlineEl.createDiv({cls:"onemind-outline-list"}),this.statusEl=this.canvasEl.createDiv({cls:"onemind-status"}),this.registerDomEvent(this.canvasEl,"wheel",n=>this.onWheel(n)),this.registerDomEvent(this.canvasEl,"pointerdown",n=>this.onPointerDown(n)),this.registerDomEvent(window,"pointermove",n=>this.onPointerMove(n)),this.registerDomEvent(window,"pointerup",n=>this.onPointerUp(n)),this.registerDomEvent(this.contentEl,"keydown",n=>this.onKeyDown(n)),this.registerEvent(this.app.vault.on("modify",async n=>{if(!(!this.file||n.path!==this.file.path||this.isSaving)){if(this.hasLocalUnsavedChange){this.showConflictModal();return}this.loadFile()}})),await this.loadFile()}async openMarkdown(){this.file&&await this.leaf.setViewState({type:"markdown",state:{file:this.file.path},active:!0})}applySettings(){this.contentEl.toggleClass("is-animation-disabled",!this.plugin.settings.animations),this.render()}addIconButton(e,t,i,n){let r=e.createEl("button",{cls:"clickable-icon onemind-tool-button",attr:{"aria-label":i,title:i}});(0,h.setIcon)(r,t),r.onClickEvent(n)}async loadFile(){if(!this.file||!(this.file instanceof h.TFile)){this.renderEmpty(this.plugin.t("emptyView"));return}let e=await this.app.vault.read(this.file),t=A(e,this.file.basename);this.root=t.root,this.documentPrefix=t.prefix,ue(this.root,this.plugin.settings.defaultExpandDepth),this.lastSavedMarkdown=e,this.hasLocalUnsavedChange=!1,this.history=[],this.future=[],this.selectedId=this.selectedId&&y(this.root,this.selectedId)?this.selectedId:this.root.id,this.selectedIds=new Set([this.selectedId]),this.render(),this.applyPendingFocus()}renderEmpty(e){var t,i;this.root=null,(t=this.nodesEl)==null||t.empty(),(i=this.svgEl)==null||i.empty(),this.contentEl.createDiv({cls:"onemind-empty",text:e})}render(){if(!this.root)return;let e=this.tagFilter?he(this.root,this.tagFilter):this.root;if(this.positions=e?oe(e,this.plugin.settings.branchColors,this.plugin.settings.layoutDirection):[],this.refreshSearchMatches(),this.nodesEl.empty(),this.svgEl.empty(),this.dragMarkerEl=null,this.positions.length===0){this.renderOutline(),this.applyTransform();return}let t=ee(this.positions);this.svgEl.setAttrs({width:`${Math.max(1,t.width+300)}`,height:`${Math.max(1,t.height+180)}`,viewBox:`${t.minX-80} ${t.minY-80} ${t.width+160} ${t.height+160}`});for(let i of this.positions)i.parent&&this.drawLink(i);this.plugin.settings.showAssociationLinks&&this.drawAssociationLinks();for(let i of this.positions)this.renderNode(i);this.renderOutline(),this.updateStatus(),this.applyTransform()}renderOutline(){if(!this.root||!this.outlineListEl)return;if(this.outlineEl.toggleClass("is-hidden",!this.outlineVisible),this.outlineListEl.empty(),this.tagFilter){let t=this.outlineListEl.createDiv({cls:"onemind-outline-filter"});t.createSpan({text:`${this.plugin.t("tagFilterActive")}: #${this.tagFilter}`}),t.createEl("button",{text:"x"}).onClickEvent(()=>this.clearTagFilter())}let e=(t,i)=>{if(this.tagFilter&&!ce(t,this.tagFilter))return;let n=this.outlineListEl.createDiv({cls:"onemind-outline-row"});n.toggleClass("is-selected",this.selectedIds.has(t.id)),n.style.paddingLeft=`${8+i*14}px`;let r=k(t.text);if(r.emoji&&n.createSpan({cls:"onemind-outline-emoji",text:r.emoji}),n.createSpan({cls:"onemind-outline-label",text:H(r.displayLabel)}),t.collapsed&&t.children.length>0&&n.createSpan({cls:"onemind-outline-count",text:`${t.children.length}`}),n.onClickEvent(d=>{d.stopPropagation(),this.selectAndFocusNode(t.id)}),!t.collapsed)for(let d of t.children)e(d,i+1)};e(this.root,0)}renderNode(e){let t=this.nodesEl.createDiv({cls:"onemind-node"});t.toggleClass("is-root",e.depth===0),t.toggleClass("is-selected",this.selectedIds.has(e.node.id)),t.toggleClass("is-primary-selected",e.node.id===this.selectedId),t.toggleClass("is-search-match",this.searchMatches.includes(e.node.id)),t.toggleClass("is-active-search-match",this.activeSearchIndex>=0&&this.searchMatches[this.activeSearchIndex]===e.node.id),t.toggleClass("is-collapsed",e.node.collapsed),t.setAttr("data-node-id",e.node.id),t.setAttr("data-node-anchor",N(e.node.text)),t.setAttr("tabindex","0"),t.style.left=`${e.x}px`,t.style.top=`${e.y}px`,t.style.width=`${e.width}px`,t.style.borderColor=e.color;let i=k(e.node.text);i.emoji&&(t.createDiv({cls:"onemind-node-emoji",text:i.emoji}),t.addClass("has-emoji"));let n=t.createDiv({cls:"onemind-node-content"});if(Te(n,i.displayLabel,this.app),i.tags.length>0){let r=t.createDiv({cls:"onemind-node-tags"});for(let d of i.tags){let a=r.createSpan({cls:"onemind-node-tag",text:`#${d}`});a.toggleClass("is-active",this.tagFilter===d),a.onClickEvent(c=>{c.stopPropagation(),this.setTagFilter(d)})}}if(e.node.children.length>0){let r=t.createDiv({cls:"onemind-collapse"});(0,h.setIcon)(r,e.node.collapsed?"chevron-right":"chevron-down"),r.onClickEvent(d=>{d.stopPropagation(),this.toggleCollapse(e.node.id)})}t.onClickEvent(r=>this.handleNodeClick(r,e.node.id)),t.ondblclick=()=>this.editNode(e.node.id),t.addEventListener("pointerdown",r=>this.onNodePointerDown(r,e)),t.addEventListener("pointermove",()=>this.clearLongPressTimer()),t.addEventListener("pointerleave",()=>this.clearLongPressTimer()),t.addEventListener("contextmenu",r=>this.openNodeMenu(r,e.node.id))}openNodeMenu(e,t){e.preventDefault(),e.stopPropagation(),this.selectedIds.has(t)||(this.selectedId=t,this.selectedIds=new Set([t]),this.render());let i=new h.Menu;i.addItem(n=>n.setTitle(this.plugin.t("addChild")).setIcon("plus").onClick(()=>this.addChildNode())),i.addItem(n=>n.setTitle(this.plugin.t("addSibling")).setIcon("corner-down-right").onClick(()=>this.addSiblingNode())),i.addItem(n=>n.setTitle(this.plugin.t("editNode")).setIcon("pencil").onClick(()=>this.editSelectedNode())),i.addSeparator(),i.addItem(n=>n.setTitle(this.plugin.t("duplicateNode")).setIcon("copy-plus").onClick(()=>this.duplicateSelectedNode())),i.addItem(n=>n.setTitle(this.plugin.t("copyBranch")).setIcon("copy").onClick(()=>void this.copySelectedBranchMarkdown())),i.addItem(n=>n.setTitle(this.plugin.t("copyNodeLink")).setIcon("link").onClick(()=>void this.copySelectedNodeLink())),i.addSeparator(),i.addItem(n=>n.setTitle(this.plugin.t("setEmoji")).setIcon("smile").onClick(()=>this.openEmojiPicker())),i.addItem(n=>n.setTitle(this.plugin.t("addTag")).setIcon("tag").onClick(()=>this.openTagModal())),i.addItem(n=>n.setTitle(this.plugin.t("clearTags")).setIcon("tag").onClick(()=>this.clearSelectedTags())),i.addSeparator(),i.addItem(n=>n.setTitle(this.plugin.t("exportSelectedSvg")).setIcon("download").onClick(()=>void this.exportSelectedSvg())),i.addItem(n=>n.setTitle(this.plugin.t("exportSelectedPng")).setIcon("image").onClick(()=>void this.exportSelectedPng())),i.addSeparator(),i.addItem(n=>n.setTitle(this.plugin.t("deleteNode")).setIcon("trash-2").onClick(()=>this.deleteSelectedNode())),i.showAtMouseEvent(e)}openNodeMenuAt(e,t,i){let n=new MouseEvent("contextmenu",{clientX:t,clientY:i,bubbles:!0,cancelable:!0});this.openNodeMenu(n,e)}drawLink(e){let t=this.positions.find(v=>v.node===e.parent);if(!t)return;let i=U("path"),n=e.x>=t.x,r=n?t.x+t.width:t.x,d=t.y+S/2,a=n?e.x:e.x+e.width,c=e.y+S/2,u=Math.max(60,Math.abs(a-r)*.45)*(n?1:-1);i.setAttrs({d:`M ${r} ${d} C ${r+u} ${d}, ${a-u} ${c}, ${a} ${c}`,stroke:e.color,fill:"none"}),this.svgEl.appendChild(i)}drawAssociationLinks(){let e=new Map;for(let i of this.positions)e.set(N(i.node.text),i);let t=new Set;for(let i of this.positions){let n=ae(i.node.text,this.file);for(let r of n){let d=e.get(r);if(!d||d.node.id===i.node.id)continue;let a=[i.node.id,d.node.id].sort().join(":");t.has(a)||(t.add(a),this.drawAssociationLink(i,d))}}}drawAssociationLink(e,t){let i=U("path");i.addClass("onemind-association-link");let n=e.x+e.width/2,r=e.y+S/2,d=t.x+t.width/2,a=t.y+S/2,c=(n+d)/2,u=Math.max(80,Math.abs(a-r)*.35),v=Math.min(r,a)-u;i.setAttrs({d:`M ${n} ${r} Q ${c} ${v}, ${d} ${a}`,fill:"none"}),this.svgEl.appendChild(i)}selectNode(e){this.selectedId=e,this.selectedIds=new Set([e]),this.render()}selectAndFocusNode(e){this.selectedId=e,this.selectedIds=new Set([e]),this.expandAncestors(e),this.render(),this.focusSelected()}applyPendingFocus(){if(!this.root||!this.pendingFocus)return;let e=this.resolveFocusTarget(this.pendingFocus);this.pendingFocus=null,e&&window.setTimeout(()=>this.selectAndFocusNode(e.id),0)}resolveFocusTarget(e){if(!this.root)return null;if(e.node){let t=y(this.root,e.node);if(t)return t;let i=Z(this.root,e.node);if(i)return i}return e.heading?Z(this.root,e.heading):null}handleNodeClick(e,t){if(e.metaKey||e.ctrlKey){this.toggleNodeSelection(t);return}if(e.shiftKey&&this.selectedId){this.selectRange(this.selectedId,t);return}this.selectNode(t)}toggleNodeSelection(e){var t;this.selectedIds.has(e)&&this.selectedIds.size>1?(this.selectedIds.delete(e),this.selectedId===e&&(this.selectedId=(t=this.selectedIds.values().next().value)!=null?t:null)):(this.selectedIds.add(e),this.selectedId=e),this.render()}selectRange(e,t){let i=this.positions.map(c=>c.node.id),n=i.indexOf(e),r=i.indexOf(t);if(n===-1||r===-1){this.selectNode(t);return}let[d,a]=nthis.setSelectedEmoji(e)).open()}clearSelectedEmoji(){this.setSelectedEmoji(null)}openTagModal(){!this.root||this.selectedIds.size===0||new W(this.app,this.plugin.t("enterTag"),e=>this.addTagToSelected(e)).open()}openTagFilterModal(){this.root&&new Q(this.app,this.getAllTags(),this.plugin.t("filterByTag"),e=>this.setTagFilter(e)).open()}clearTagFilter(){this.tagFilter=null,this.render()}setTagFilter(e){let t=J(e);if(!t)return;this.tagFilter=t,this.render();let i=this.positions.find(n=>k(n.node.text).tags.includes(t));i&&this.selectAndFocusNode(i.node.id)}getAllTags(){return this.root?[...new Set(B(this.root).flatMap(e=>k(e.text).tags))].sort():[]}clearSelectedTags(){if(!this.root)return;let e=this.getSelectedNodes();if(e.length!==0){this.pushHistory();for(let t of e)t.text=Pe(t.text);this.scheduleSave(),this.render()}}addTagToSelected(e){if(!this.root)return;let t=J(e);if(!t)return;let i=this.getSelectedNodes();if(i.length!==0){this.pushHistory();for(let n of i)n.text=De(n.text,t);this.scheduleSave(),this.render()}}setSelectedEmoji(e){if(!this.root)return;let t=this.getSelectedNodes();if(t.length!==0){this.pushHistory();for(let i of t){let n=re(i.text);i.text=e?`${e} ${n.label}`:n.label}this.scheduleSave(),this.render()}}editNode(e){if(!this.root)return;let t=y(this.root,e),i=this.nodesEl.querySelector(`[data-node-id="${e}"]`);if(!t||!i)return;this.selectedId=e,i.empty(),i.addClass("is-editing");let n=i.createEl("textarea",{cls:"onemind-editor"});n.value=t.text,n.focus(),n.select();let r=!1,d=()=>{if(r)return;r=!0;let a=n.value.trim()||"Untitled";this.pushHistory(),t.text=a,this.scheduleSave(),this.render()};n.addEventListener("keydown",a=>{a.key==="Enter"&&!a.shiftKey&&(a.preventDefault(),d()),a.key==="Escape"&&(a.preventDefault(),this.render())}),n.addEventListener("blur",d,{once:!0})}addChildNode(){var i,n;if(!this.root)return;let e=(n=y(this.root,(i=this.selectedId)!=null?i:this.root.id))!=null?n:this.root;this.pushHistory();let t=E("New idea",R(e,I(this.root,e.id)));e.children.push(t),e.collapsed=!1,this.selectedId=t.id,this.scheduleSave(),this.render(),window.setTimeout(()=>this.editNode(t.id),20)}addSiblingNode(){if(!this.root||!this.selectedId||this.selectedId===this.root.id){this.addChildNode();return}let e=b(this.root,this.selectedId),t=y(this.root,this.selectedId);if(!e||!t)return;this.pushHistory();let i=e.children.findIndex(r=>r.id===t.id),n=E("New idea",t.kind);e.children.splice(i+1,0,n),this.selectedId=n.id,this.scheduleSave(),this.render(),window.setTimeout(()=>this.editNode(n.id),20)}duplicateSelectedNode(){if(!this.root||!this.selectedId||this.selectedId===this.root.id)return;let e=b(this.root,this.selectedId),t=y(this.root,this.selectedId);if(!e||!t)return;this.pushHistory();let i=e.children.findIndex(r=>r.id===t.id),n=le(t);e.children.splice(i+1,0,n),this.selectedId=n.id,this.selectedIds=new Set([n.id]),this.scheduleSave(),this.render(),this.focusSelected()}moveSelectedNode(e){if(!this.root||!this.selectedId||this.selectedId===this.root.id)return;if(this.pushHistory(),!Oe(this.root,this.selectedId,e)){this.history.pop();return}this.scheduleSave(),this.render(),this.focusSelected()}deleteSelectedNode(){var i;if(!this.root)return;let e=this.getEditableSelectedIds();if(e.length===0)return;this.pushHistory();let t=this.selectedId?b(this.root,this.selectedId):null;ge(this.root,new Set(e)),this.selectedId=(i=t==null?void 0:t.id)!=null?i:this.root.id,this.selectedIds=new Set([this.selectedId]),this.scheduleSave(),this.render()}toggleCollapse(e){if(!this.root)return;let t=y(this.root,e);t&&(t.collapsed=!t.collapsed,this.render())}toggleSelectedNode(){if(!this.root)return;let e=this.getSelectedNodes();if(e.length===0)return;let t=e.some(i=>!i.collapsed&&i.children.length>0);for(let i of e)i.children.length>0&&(i.collapsed=t);this.render()}expandAllNodes(){this.root&&(j(this.root,!1),this.render())}collapseAllNodes(){this.root&&(j(this.root,!0),this.root.collapsed=!1,this.selectedId=this.root.id,this.render(),this.focusSelected())}scheduleSave(){!this.root||!this.file||(this.hasLocalUnsavedChange=!0,this.updateStatus(),this.saveTimer&&window.clearTimeout(this.saveTimer),this.saveTimer=window.setTimeout(()=>void this.saveNow(),this.plugin.settings.autoSaveDelay))}showConflictModal(){this.conflictModalOpen||(this.conflictModalOpen=!0,new q(this.app,{title:this.plugin.t("conflictTitle"),body:this.plugin.t("conflictBody"),reload:this.plugin.t("reloadMarkdown"),keep:this.plugin.t("keepEdits")},async()=>{this.conflictModalOpen=!1,await this.loadFile(),new h.Notice(this.plugin.t("reloadedExternal"))},async()=>{this.conflictModalOpen=!1,await this.saveNow(),new h.Notice(this.plugin.t("keptLocal"))},()=>{this.conflictModalOpen=!1}).open())}async saveNow(){if(!this.root||!this.file)return;let e=this.serializeCurrentMarkdown();this.isSaving=!0,this.updateStatus(),await this.app.vault.modify(this.file,e),this.lastSavedMarkdown=e,this.hasLocalUnsavedChange=!1,this.isSaving=!1,this.updateStatus()}pushHistory(){this.root&&(this.history.push(this.serializeCurrentMarkdown()),this.history.length>80&&this.history.shift(),this.future=[])}undo(){var i,n;if(!this.root||this.history.length===0)return;this.future.push(this.serializeCurrentMarkdown());let e=this.history.pop();if(!e)return;let t=A(e,(n=(i=this.file)==null?void 0:i.basename)!=null?n:"Mind map");this.root=t.root,this.documentPrefix=t.prefix,this.selectedId=this.root.id,this.selectedIds=new Set([this.root.id]),this.render(),this.saveNow()}redo(){var i,n;if(!this.root)return;let e=this.future.pop();if(!e)return;this.history.push(this.serializeCurrentMarkdown());let t=A(e,(n=(i=this.file)==null?void 0:i.basename)!=null?n:"Mind map");this.root=t.root,this.documentPrefix=t.prefix,this.selectedId=this.root.id,this.selectedIds=new Set([this.root.id]),this.render(),this.saveNow()}onKeyDown(e){if(e.target instanceof HTMLTextAreaElement)return;let t=e.ctrlKey||e.metaKey;if(t&&e.key.toLowerCase()==="z"&&e.shiftKey){e.preventDefault(),this.redo();return}if(t&&e.key.toLowerCase()==="z"){e.preventDefault(),this.undo();return}if(t&&e.key.toLowerCase()==="a"){e.preventDefault(),this.selectAllNodes();return}if(t&&e.key==="="){e.preventDefault(),this.zoomAtCenter(1.1);return}if(t&&e.key==="-"){e.preventDefault(),this.zoomAtCenter(.9);return}if(t&&e.shiftKey&&e.key.toLowerCase()==="f"){e.preventDefault(),this.fitToView();return}if(t&&e.key.toLowerCase()==="f"){e.preventDefault(),this.focusSelected();return}if(e.altKey&&e.key==="ArrowUp"){e.preventDefault(),this.moveSelectedNode("up");return}if(e.altKey&&e.key==="ArrowDown"){e.preventDefault(),this.moveSelectedNode("down");return}if(e.altKey&&e.key==="ArrowRight"){e.preventDefault(),this.moveSelectedNode("indent");return}if(e.altKey&&e.key==="ArrowLeft"){e.preventDefault(),this.moveSelectedNode("outdent");return}e.key==="Tab"?(e.preventDefault(),this.addChildNode()):e.key==="/"?(e.preventDefault(),this.focusSearch()):e.key==="Enter"?(e.preventDefault(),this.addSiblingNode()):e.key==="F2"?(e.preventDefault(),this.selectedId&&this.editNode(this.selectedId)):e.key==="Delete"||e.key==="Backspace"?(e.preventDefault(),this.deleteSelectedNode()):e.key===" "?(e.preventDefault(),this.selectedId&&this.toggleCollapse(this.selectedId)):e.key.startsWith("Arrow")&&(e.preventDefault(),this.moveSelection(e.key))}onSearchKeyDown(e){e.key==="Enter"?(e.preventDefault(),this.jumpSearch(e.shiftKey?-1:1)):e.key==="Escape"?(e.preventDefault(),this.searchInputEl.value="",this.updateSearch(""),this.contentEl.focus()):(e.key==="ArrowDown"||e.key==="ArrowUp")&&(e.preventDefault(),this.jumpSearch(e.key==="ArrowDown"?1:-1))}focusSearch(){this.searchInputEl.focus(),this.searchInputEl.select()}toggleOutline(e){this.outlineVisible=e!=null?e:!this.outlineVisible,this.renderOutline()}selectAllNodes(){this.root&&(this.selectedIds=new Set(B(this.root).map(e=>e.id)),this.selectedId=this.root.id,this.render())}async exportSvg(){!this.root||!this.file||this.positions.length===0||await this.exportSvgFromPositions(this.positions,this.file.basename,this.getExportPath("svg"))}async exportPng(){!this.root||!this.file||this.positions.length===0||await this.exportPngFromPositions(this.positions,this.file.basename,this.getExportPath("png"))}async exportSelectedSvg(){let e=this.getSelectedBranchExport();e&&await this.exportSvgFromPositions(e.positions,e.title,this.getExportPath("svg",e.slug))}async exportSelectedPng(){let e=this.getSelectedBranchExport();e&&await this.exportPngFromPositions(e.positions,e.title,this.getExportPath("png",e.slug))}async exportSvgFromPositions(e,t,i){let n=se(e,t,this.plugin.settings.showAssociationLinks);await this.writeTextFile(i,n),new h.Notice(`${this.plugin.t("exported")} ${i}`)}async exportPngFromPositions(e,t,i){let n=se(e,t,this.plugin.settings.showAssociationLinks),r=await Ie(n,this.plugin.settings.exportScale,this.plugin.settings.exportTransparentBackground),d=this.app.vault.getAbstractFileByPath(i);d instanceof h.TFile?await this.app.vault.modifyBinary(d,r):await this.app.vault.createBinary(i,r),new h.Notice(`${this.plugin.t("exported")} ${i}`)}getSelectedBranchExport(){if(!this.root||!this.selectedId)return null;let e=y(this.root,this.selectedId);if(!e)return null;let t=F(e);j(t,!1);let i=oe(t,this.plugin.settings.branchColors,this.plugin.settings.layoutDirection),n=de(e.text);return{positions:i,title:n,slug:N(e.text)||"branch"}}async copySelectedNodeLink(){if(!this.file||!this.root||!this.selectedId)return;let e=y(this.root,this.selectedId);if(!e)return;let t=this.app.vault.getName(),i=this.file.path,n=N(e.text),r=`obsidian://open?vault=${encodeURIComponent(t)}&file=${encodeURIComponent(i)}#${encodeURIComponent(n)}`;await navigator.clipboard.writeText(r),new h.Notice(this.plugin.t("copiedLink"))}async copySelectedBranchMarkdown(){if(!this.root||!this.selectedId)return;let e=y(this.root,this.selectedId);e&&(await navigator.clipboard.writeText(ne(F(e))),new h.Notice(this.plugin.t("copiedBranch")))}async pasteMarkdownAsChild(){var i,n;if(!this.root)return;let e=(n=y(this.root,(i=this.selectedId)!=null?i:this.root.id))!=null?n:this.root,t=await this.readClipboardNodes(e.kind,I(this.root,e.id)+1);t.length!==0&&(this.pushHistory(),e.children.push(...t),e.collapsed=!1,this.selectedId=t[0].id,this.selectedIds=new Set([t[0].id]),this.scheduleSave(),this.render(),new h.Notice(this.plugin.t("pastedMarkdown")))}async pasteMarkdownAsSibling(){if(!this.root||!this.selectedId||this.selectedId===this.root.id){await this.pasteMarkdownAsChild();return}let e=y(this.root,this.selectedId),t=b(this.root,this.selectedId);if(!e||!t)return;let i=await this.readClipboardNodes(e.kind,I(this.root,e.id));if(i.length===0)return;this.pushHistory();let n=t.children.findIndex(r=>r.id===e.id);t.children.splice(n+1,0,...i),this.selectedId=i[0].id,this.selectedIds=new Set([i[0].id]),this.scheduleSave(),this.render(),new h.Notice(this.plugin.t("pastedMarkdown"))}async readClipboardNodes(e,t){let i=await navigator.clipboard.readText();if(!i.trim())return new h.Notice(this.plugin.t("clipboardEmpty")),[];let n=A(i,"Clipboard"),d=(n.root.text==="Clipboard"&&n.root.children.length>0?n.root.children:[n.root]).map(a=>F(a));for(let a of d)pe(a,e,t);return d.length===0&&new h.Notice(this.plugin.t("clipboardEmpty")),d}updateSearch(e){if(this.searchQuery=e.trim().toLowerCase(),this.refreshSearchMatches(),this.activeSearchIndex=this.searchMatches.length>0?0:-1,this.activeSearchIndex>=0){let t=this.searchMatches[this.activeSearchIndex];t&&this.selectAndFocusNode(t)}else this.render();this.updateSearchCount()}refreshSearchMatches(){if(!this.root||this.searchQuery.length===0){this.searchMatches=[],this.activeSearchIndex=-1,this.updateSearchCount();return}this.searchMatches=B(this.root).filter(e=>e.text.toLowerCase().includes(this.searchQuery)).map(e=>e.id),this.activeSearchIndex>=this.searchMatches.length&&(this.activeSearchIndex=this.searchMatches.length-1),this.searchMatches.length===0&&(this.activeSearchIndex=-1),this.updateSearchCount()}updateSearchCount(){if(!this.searchCountEl)return;if(this.searchQuery.length===0){this.searchCountEl.setText("");return}let e=this.activeSearchIndex>=0?this.activeSearchIndex+1:0;this.searchCountEl.setText(`${e}/${this.searchMatches.length}`)}jumpSearch(e){if(this.searchMatches.length===0)return;this.activeSearchIndex=(this.activeSearchIndex+e+this.searchMatches.length)%this.searchMatches.length;let t=this.searchMatches[this.activeSearchIndex];t&&this.selectAndFocusNode(t),this.updateSearchCount()}moveSelection(e){if(!this.root||!this.selectedId)return;let t=y(this.root,this.selectedId);if(!t)return;if(e==="ArrowRight"){if(t.collapsed&&t.children.length>0){this.toggleCollapse(t.id);return}let d=t.children[0];d&&this.selectAndFocusNode(d.id);return}if(e==="ArrowLeft"){let d=b(this.root,t.id);t.children.length>0&&!t.collapsed?this.toggleCollapse(t.id):d&&this.selectAndFocusNode(d.id);return}let i=this.positions.slice().sort((d,a)=>d.y-a.y||d.x-a.x),n=i.findIndex(d=>d.node.id===t.id),r=i[n+(e==="ArrowDown"?1:-1)];r&&this.selectAndFocusNode(r.node.id)}expandAncestors(e){if(!this.root)return;let t=fe(this.root,e);for(let i of t.slice(0,-1))i.collapsed=!1}getSelectedNodes(){return this.root?[...this.selectedIds].map(e=>y(this.root,e)).filter(e=>!!e):[]}getEditableSelectedNodes(){return this.root?this.getSelectedNodes().filter(e=>{var t;return e.id!==((t=this.root)==null?void 0:t.id)}):[]}getEditableSelectedIds(){return this.getEditableSelectedNodes().map(e=>e.id)}onWheel(e){e.preventDefault();let t=e.deltaY>0?.92:1.08;this.scale=D(this.scale*t,.35,2.2),this.applyTransform()}onPointerDown(e){let t=e.target;t.closest(".onemind-node")||t.closest(".onemind-toolbar")||(this.panning={x:e.clientX,y:e.clientY,panX:this.panX,panY:this.panY},this.canvasEl.setPointerCapture(e.pointerId))}onPointerMove(e){if(this.dragState){this.onNodePointerMove(e);return}this.panning&&(this.panX=this.panning.panX+e.clientX-this.panning.x,this.panY=this.panning.panY+e.clientY-this.panning.y,this.applyTransform())}onPointerUp(e){if(this.dragState){this.finishNodeDrag(e);return}this.stopPanning()}stopPanning(){this.panning=null}onNodePointerDown(e,t){var r;if(e.button!==0||t.node.id===((r=this.root)==null?void 0:r.id)||e.target instanceof HTMLTextAreaElement)return;let i=e.target;if(i.closest(".onemind-collapse")||i.closest(".onemind-wikilink"))return;e.stopPropagation(),this.selectedId=t.node.id,this.selectedIds=new Set([t.node.id]),this.nodesEl.querySelectorAll(".is-selected").forEach(d=>d.removeClass("is-selected")),e.currentTarget.addClass("is-selected");let n=this.toScenePoint(e.clientX,e.clientY);this.dragState={nodeId:t.node.id,pointerId:e.pointerId,startClientX:e.clientX,startClientY:e.clientY,offsetX:n.x-t.x,offsetY:n.y-t.y,originalX:t.x,originalY:t.y,dragging:!1,el:e.currentTarget},this.dragState.el.setPointerCapture(e.pointerId),(e.pointerType==="touch"||e.pointerType==="pen")&&(this.longPressTimer=window.setTimeout(()=>{this.dragState=null,this.openNodeMenuAt(t.node.id,e.clientX,e.clientY)},560))}onNodePointerMove(e){if(!this.dragState)return;this.clearLongPressTimer();let t=e.clientX-this.dragState.startClientX,i=e.clientY-this.dragState.startClientY;if(!this.dragState.dragging&&Math.hypot(t,i)!n.has(l.node.id)),d=r.find(l=>e>=l.x&&e<=l.x+l.width&&t>=l.y&&t<=l.y+l.height);if(d)return{mode:"child",parentId:d.node.id,index:d.node.children.length,targetId:d.node.id};let a=null,c=Number.POSITIVE_INFINITY;for(let l of r){if(l.node.id===this.root.id)continue;let f=Math.hypot(e-(l.x+l.width/2),t-(l.y+l.height/2));fl.id===a.node.id),p=t{var d;return r.node.id===((d=this.dropTarget)==null?void 0:d.targetId)});if(!e)return;this.dragMarkerEl=this.nodesEl.createDiv({cls:"onemind-drop-marker"});let t=this.dropTarget.index<=((n=(i=b(this.root,this.dropTarget.targetId))==null?void 0:i.children.findIndex(r=>{var d;return r.id===((d=this.dropTarget)==null?void 0:d.targetId)}))!=null?n:0);this.dragMarkerEl.style.left=`${e.x-10}px`,this.dragMarkerEl.style.top=`${t?e.y-C/2:e.y+e.height+C/2}px`,this.dragMarkerEl.style.width=`${e.width+20}px`}clearDropTarget(){var e;this.nodesEl.querySelectorAll(".is-drop-child").forEach(t=>t.removeClass("is-drop-child")),(e=this.dragMarkerEl)==null||e.remove(),this.dragMarkerEl=null}clearLongPressTimer(){this.longPressTimer&&(window.clearTimeout(this.longPressTimer),this.longPressTimer=null)}toScenePoint(e,t){let i=this.canvasEl.getBoundingClientRect();return{x:(e-i.left-this.panX)/this.scale,y:(t-i.top-this.panY)/this.scale}}serializeCurrentMarkdown(){return this.root?`${this.documentPrefix}${ne(this.root)}`:this.documentPrefix}getExportPath(e,t){var r,d,a,c;let i=(d=(r=this.file)==null?void 0:r.parent)!=null&&d.path&&this.file.parent.path!=="/"?`${this.file.parent.path}/`:"",n=t?`.${t}`:"";return`${i}${(c=(a=this.file)==null?void 0:a.basename)!=null?c:"mind-map"}.onemind${n}.${e}`}async writeTextFile(e,t){let i=this.app.vault.getAbstractFileByPath(e);i instanceof h.TFile?await this.app.vault.modify(i,t):await this.app.vault.create(e,t)}zoomAtCenter(e){this.scale=D(this.scale*e,.35,2.2),this.applyTransform()}focusSelected(){let e=this.positions.find(i=>i.node.id===this.selectedId);if(!e)return;let t=this.canvasEl.getBoundingClientRect();this.panX=t.width/2-(e.x+e.width/2)*this.scale,this.panY=t.height/2-(e.y+S/2)*this.scale,this.applyTransform()}fitToView(){if(this.positions.length===0)return;let e=ee(this.positions),t=this.canvasEl.getBoundingClientRect(),i=(t.width-120)/Math.max(e.width,1),n=(t.height-120)/Math.max(e.height,1);this.scale=D(Math.min(i,n),.35,1.4),this.panX=t.width/2-(e.minX+e.width/2)*this.scale,this.panY=t.height/2-(e.minY+e.height/2)*this.scale,this.applyTransform()}applyTransform(){this.sceneEl&&(this.sceneEl.style.transform=`translate(${this.panX}px, ${this.panY}px) scale(${this.scale})`,this.updateStatus())}updateStatus(){if(!this.statusEl)return;let e=this.root?B(this.root).length:0,t=this.positions.length,i=this.isSaving?this.plugin.t("saving"):this.hasLocalUnsavedChange?this.plugin.t("unsaved"):this.plugin.t("saved"),n=this.tagFilter?`#${this.tagFilter}`:"";this.statusEl.setText([`${t}/${e} ${this.plugin.t("nodesLabel")}`,`${this.selectedIds.size} ${this.plugin.t("selectedLabel")}`,`${this.plugin.t("zoomLabel")} ${Math.round(this.scale*100)}%`,n,i].filter(Boolean).join(" \xB7 "))}},G=class extends h.PluginSettingTab{constructor(s,e){super(s,e),this.plugin=e}display(){let{containerEl:s}=this;s.empty(),s.createEl("h2",{text:this.plugin.t("settingsTitle")}),new h.Setting(s).setName(this.plugin.t("language")).setDesc(this.plugin.t("languageDesc")).addDropdown(t=>{t.addOption("zh",this.plugin.t("chinese")).addOption("en",this.plugin.t("english")).setValue(this.plugin.settings.language).onChange(async i=>{this.plugin.settings.language=i,await this.plugin.saveSettings(),this.plugin.refreshOpenViews(),this.display()})}),new h.Setting(s).setName(this.plugin.t("autoSaveDelay")).setDesc(this.plugin.t("autoSaveDelayDesc")).addText(t=>{t.setPlaceholder("300").setValue(String(this.plugin.settings.autoSaveDelay)).onChange(async i=>{let n=Number.parseInt(i,10);this.plugin.settings.autoSaveDelay=Number.isFinite(n)?D(n,0,5e3):$.autoSaveDelay,await this.plugin.saveSettings(),this.plugin.refreshOpenViews()})}),new h.Setting(s).setName(this.plugin.t("defaultExpandDepth")).setDesc(this.plugin.t("defaultExpandDepthDesc")).addSlider(t=>{t.setLimits(1,99,1).setDynamicTooltip().setValue(this.plugin.settings.defaultExpandDepth).onChange(async i=>{this.plugin.settings.defaultExpandDepth=i,await this.plugin.saveSettings(),this.plugin.refreshOpenViews()})}),new h.Setting(s).setName(this.plugin.t("layoutDirection")).setDesc(this.plugin.t("layoutDirectionDesc")).addDropdown(t=>{t.addOption("right",this.plugin.t("horizontalRight")).addOption("balanced",this.plugin.t("balanced")).setValue(this.plugin.settings.layoutDirection).onChange(async i=>{this.plugin.settings.layoutDirection=i,await this.plugin.saveSettings(),this.plugin.refreshOpenViews()})}),new h.Setting(s).setName(this.plugin.t("layoutAnimation")).setDesc(this.plugin.t("layoutAnimationDesc")).addToggle(t=>{t.setValue(this.plugin.settings.animations).onChange(async i=>{this.plugin.settings.animations=i,await this.plugin.saveSettings(),this.plugin.refreshOpenViews()})}),new h.Setting(s).setName(this.plugin.t("showAssociationLinks")).setDesc(this.plugin.t("showAssociationLinksDesc")).addToggle(t=>{t.setValue(this.plugin.settings.showAssociationLinks).onChange(async i=>{this.plugin.settings.showAssociationLinks=i,await this.plugin.saveSettings(),this.plugin.refreshOpenViews()})}),new h.Setting(s).setName(this.plugin.t("branchColors")).setDesc(this.plugin.t("branchColorsDesc")).addTextArea(t=>{t.setValue(this.plugin.settings.branchColors.join(", ")).onChange(async i=>{let n=i.split(",").map(r=>r.trim()).filter(Boolean);this.plugin.settings.branchColors=n.length>0?n:$.branchColors,await this.plugin.saveSettings(),this.plugin.refreshOpenViews()}),t.inputEl.rows=2});let e=s.createDiv({cls:"onemind-settings-palette"});this.plugin.settings.branchColors.forEach((t,i)=>{new h.Setting(e).setName(`${this.plugin.t("branchColors")} ${i+1}`).addColorPicker(n=>{n.setValue(t).onChange(async r=>{this.plugin.settings.branchColors[i]=r,await this.plugin.saveSettings(),this.plugin.refreshOpenViews()})})}),new h.Setting(s).setName(this.plugin.t("resetBranchColors")).addButton(t=>{t.setButtonText(this.plugin.t("resetBranchColors")).onClick(async()=>{this.plugin.settings.branchColors=[...$.branchColors],await this.plugin.saveSettings(),this.plugin.refreshOpenViews(),this.display()})}),new h.Setting(s).setName(this.plugin.t("pngScale")).setDesc(this.plugin.t("pngScaleDesc")).addSlider(t=>{t.setLimits(1,4,1).setDynamicTooltip().setValue(this.plugin.settings.exportScale).onChange(async i=>{this.plugin.settings.exportScale=i,await this.plugin.saveSettings()})}),new h.Setting(s).setName(this.plugin.t("transparentPng")).setDesc(this.plugin.t("transparentPngDesc")).addToggle(t=>{t.setValue(this.plugin.settings.exportTransparentBackground).onChange(async i=>{this.plugin.settings.exportTransparentBackground=i,await this.plugin.saveSettings()})})}},_=class extends h.SuggestModal{constructor(s,e,t){super(s),this.onChoose=t,this.setPlaceholder(e)}getSuggestions(s){let e=s.trim().toLowerCase();return e?te.filter(t=>t.includes(e)):te}renderSuggestion(s,e){e.createSpan({cls:"onemind-emoji-suggestion",text:s})}onChooseSuggestion(s){this.onChoose(s)}},W=class extends h.Modal{constructor(s,e,t){super(s),this.placeholder=e,this.onSubmit=t}onOpen(){this.contentEl.empty();let s=this.contentEl.createEl("input",{cls:"onemind-tag-input",attr:{type:"text",placeholder:this.placeholder}});s.focus(),s.addEventListener("keydown",e=>{e.key==="Enter"?(e.preventDefault(),this.onSubmit(s.value),this.close()):e.key==="Escape"&&(e.preventDefault(),this.close())})}onClose(){this.contentEl.empty()}},Q=class extends h.SuggestModal{constructor(s,e,t,i){super(s),this.tags=e,this.onChoose=i,this.setPlaceholder(t)}getSuggestions(s){let e=J(s).toLowerCase(),t=e?this.tags.filter(i=>i.toLowerCase().includes(e)):this.tags;return e&&!t.includes(e)?[e,...t]:t}renderSuggestion(s,e){e.createSpan({cls:"onemind-tag-suggestion",text:`#${s}`})}onChooseSuggestion(s){this.onChoose(s)}},q=class extends h.Modal{constructor(s,e,t,i,n){super(s),this.labels=e,this.onReload=t,this.onKeep=i,this.onCloseCallback=n}onOpen(){let{contentEl:s}=this;s.empty(),s.createEl("h2",{text:this.labels.title}),s.createEl("p",{text:this.labels.body});let e=s.createDiv({cls:"onemind-modal-actions"});e.createEl("button",{text:this.labels.reload}).onClickEvent(async()=>{this.close(),await this.onReload()}),e.createEl("button",{cls:"mod-cta",text:this.labels.keep}).onClickEvent(async()=>{this.close(),await this.onKeep()})}onClose(){this.contentEl.empty(),this.onCloseCallback()}};function A(o,s){let e=o.split(/\r?\n/),t=0;if(e[0]==="---"){let a=e.findIndex((c,u)=>u>0&&c==="---");a>0&&(t=a+1)}for(;tc0?`${e.slice(0,n).join(` +`).replace(/\s*$/,"")} + +`:"",d=e.slice(n).join(` +`);return{root:Ne(d,s),prefix:r}}function Ne(o,s){var d,a,c,u;let e=o.split(/\r?\n/),t=null,i=1,n=[],r=()=>(t||(t=E(s||"Center topic","heading"),n.push({depth:1,node:t})),t);for(let v of e){let p=/^(#{1,6})\s+(.+?)\s*$/.exec(v);if(p){let f=p[1].length,w=E(p[2],"heading");if(!t&&f===1)t=w,n.length=0,n.push({depth:1,node:w});else{for(r();n.length>0&&n[n.length-1].depth>=f;)n.pop();((a=(d=n[n.length-1])==null?void 0:d.node)!=null?a:t).children.push(w),n.push({depth:f,node:w})}i=f;continue}let l=/^(\s*)[-*+]\s+(.+?)\s*$/.exec(v);if(l){r();let f=Math.floor(l[1].replace(/\t/g," ").length/2),w=i+f+1,g=E(l[2],"list");for(;n.length>0&&n[n.length-1].depth>=w;)n.pop();((u=(c=n[n.length-1])==null?void 0:c.node)!=null?u:t).children.push(g),n.push({depth:w,node:g})}}return t!=null?t:E(s||"Center topic","heading")}function ne(o){let s=[],e=(t,i,n)=>{let r=t.kind==="heading"&&!n&&i<=6;r?s.push(`${"#".repeat(i)} ${t.text}`):s.push(`${" ".repeat(Math.max(0,i-2))}- ${t.text}`);for(let d of t.children)e(d,i+1,n||!r)};return e(o,1,!1),`${s.join(` +`)} +`}function se(o,s,e=!0){let t=ee(o),i=80,n=Math.ceil(t.width+i*2),r=Math.ceil(t.height+i*2),d=t.minX-i,a=t.minY-i,c=o.filter(p=>p.parent).map(p=>{let l=o.find(P=>P.node===p.parent);if(!l)return"";let f=p.x>=l.x,w=f?l.x+l.width:l.x,g=l.y+S/2,m=f?p.x:p.x+p.width,x=p.y+S/2,M=Math.max(60,Math.abs(m-w)*.45)*(f?1:-1);return``}).join(` +`),u=e?Ce(o):"",v=o.map(p=>{let l=p.depth===0,f=p.width,w=l?18:14,g=l?700:400,m=k(p.node.text),x=[m.emoji?`${m.emoji} ${H(m.displayLabel)}`:H(m.displayLabel),m.tags.map(z=>`#${z}`).join(" ")].filter(Boolean).join(" "),M=Le(x,l?20:22),P=M.map((z,me)=>{let ve=me===0?0:w+3;return`${O(z)}`}).join(""),Y=Math.max(S,24+M.length*(w+3));return[``,`${P}`].join(` +`)}).join(` +`);return['',``,"",c,u,v,""].join(` +`)}function Ce(o){let s=new Map;for(let t of o)s.set(N(t.node.text),t);let e=new Set;return o.flatMap(t=>ae(t.node.text,null).map(i=>{let n=s.get(i);if(!n||n.node.id===t.node.id)return"";let r=[t.node.id,n.node.id].sort().join(":");if(e.has(r))return"";e.add(r);let d=t.x+t.width/2,a=t.y+S/2,c=n.x+n.width/2,u=n.y+S/2,v=(d+c)/2,p=Math.max(80,Math.abs(u-a)*.35),l=Math.min(a,u)-p;return``})).filter(Boolean).join(` +`)}async function Ie(o,s,e){var a,c,u,v;let t=Number((c=(a=/width="(\d+)"/.exec(o))==null?void 0:a[1])!=null?c:1200),i=Number((v=(u=/height="(\d+)"/.exec(o))==null?void 0:u[1])!=null?v:800),n=D(Math.round(s),1,4),r=new Blob([o],{type:"image/svg+xml;charset=utf-8"}),d=URL.createObjectURL(r);try{let p=await new Promise((g,m)=>{let x=new Image;x.onload=()=>g(x),x.onerror=()=>m(new Error("Could not render SVG export.")),x.src=d}),l=document.createElement("canvas");l.width=t*n,l.height=i*n;let f=l.getContext("2d");if(!f)throw new Error("Canvas is not available.");return e||(f.fillStyle=getComputedStyle(document.body).getPropertyValue("--background-primary").trim()||"#ffffff",f.fillRect(0,0,l.width,l.height)),f.scale(n,n),f.drawImage(p,0,0,t,i),await(await new Promise((g,m)=>{l.toBlob(x=>x?g(x):m(new Error("Could not encode PNG export.")),"image/png")})).arrayBuffer()}finally{URL.revokeObjectURL(d)}}function oe(o,s,e){let t=[],i=d=>d.collapsed?[]:d.children,n=d=>{let a=i(d);return a.length===0?S:Math.max(S,a.reduce((c,u)=>c+n(u),0)+C*(a.length-1))},r=(d,a,c,u,v,p,l)=>{let f=n(d),w=v+f/2-S/2,g=c===0?200:ke;t.push({node:d,parent:a,depth:c,x:u,y:w,width:g,height:S,color:p,side:l});let m=v;i(d).forEach((M,P)=>{let Y=c===0?s[P%s.length]:p;r(M,d,c+1,u+l*X,m,Y,l),m+=n(M)+C})};if(e==="balanced"&&!o.collapsed&&o.children.length>1){let d=i(o),a=d.filter((g,m)=>m%2===1),c=d.filter((g,m)=>m%2===0),u=g=>g.length===0?S:g.reduce((m,x)=>m+n(x),0)+C*(g.length-1),v=u(a),p=u(c),l=Math.max(v,p,S);t.push({node:o,parent:null,depth:0,x:0,y:l/2-S/2,width:200,height:S,color:s[0],side:1});let f=l/2-v/2;a.forEach((g,m)=>{let x=d.findIndex(M=>M===g);r(g,o,1,-X,f,s[x%s.length],-1),f+=n(g)+C});let w=l/2-p/2;c.forEach(g=>{let m=d.findIndex(x=>x===g);r(g,o,1,X,w,s[m%s.length],1),w+=n(g)+C})}else r(o,null,0,0,0,s[0],1);return t}function Te(o,s,e){let t=/(\[\[[^\]]+\]\]|`[^`]+`|\*\*[^*]+\*\*)/g,i=0;for(let n of s.matchAll(t)){if(n.index===void 0)continue;n.index>i&&o.appendText(s.slice(i,n.index));let r=n[0];if(r.startsWith("[[")){let d=r.slice(2,-2);o.createEl("span",{cls:"onemind-wikilink",text:d}).onClickEvent(c=>{c.stopPropagation(),e.workspace.openLinkText(d,"",!1)})}else r.startsWith("`")?o.createEl("code",{text:r.slice(1,-1)}):o.createEl("strong",{text:r.slice(2,-2)});i=n.index+r.length}i(e.push(r),n)).replace(/\s{2,}/g," ").trim()||s.label;return{...s,tags:e,displayLabel:t}}function J(o){return o.trim().replace(/^#/,"").replace(/[^\p{Letter}\p{Number}_/-]/gu,"")}function De(o,s){return k(o).tags.includes(s)?o:`${o.trim()} #${s}`}function Pe(o){let s=k(o);return`${s.emoji?`${s.emoji} `:""}${s.displayLabel}`.trim()||"Untitled"}function de(o){let s=k(o);return H(`${s.emoji?`${s.emoji} `:""}${s.displayLabel}${s.tags.length>0?` ${s.tags.map(e=>`#${e}`).join(" ")}`:""}`)}function H(o){return o.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,(s,e,t)=>t!=null?t:e).replace(/`([^`]+)`/g,"$1").replace(/\*\*([^*]+)\*\*/g,"$1")}function ae(o,s){let e=[],t=s==null?void 0:s.basename.toLowerCase(),i=s==null?void 0:s.path.replace(/\.md$/i,"").toLowerCase(),n=/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;for(let r of o.matchAll(n)){let d=r[1].trim();if(!d)continue;let[a,c]=d.includes("#")?d.split("#",2):["",d],u=a.trim().replace(/\.md$/i,"").toLowerCase();u&&u!==t&&u!==i||e.push(N(c))}return e.filter(Boolean)}function Le(o,s){let e=o.trim().split(/\s+/).filter(Boolean);if(e.length===0)return["Untitled"];let t=[],i="";for(let n of e){if(n.length>s){i&&t.push(i);for(let d=0;ds&&i?(t.push(i),i=n):i=r}return i&&t.push(i),t.slice(0,4)}function O(o){return o.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function E(o,s){return{id:`node-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`,text:o.trim()||"Untitled",kind:s,children:[],collapsed:!1}}function F(o){return{id:o.id,text:o.text,kind:o.kind,collapsed:o.collapsed,children:o.children.map(s=>F(s))}}function le(o){return{id:E(o.text,o.kind).id,text:o.text,kind:o.kind,collapsed:o.collapsed,children:o.children.map(s=>le(s))}}function he(o,s){let e=o.children.map(t=>he(t,s)).filter(t=>!!t);return k(o.text).tags.includes(s)||e.length>0?{...o,collapsed:!1,children:e}:null}function ce(o,s){return k(o.text).tags.includes(s)||o.children.some(e=>ce(e,s))}function pe(o,s,e){o.id=E(o.text,o.kind).id,o.kind=s==="heading"&&e<=5?"heading":"list";for(let t of o.children)pe(t,o.kind,e+1)}function ue(o,s,e=0){o.collapsed=e>=s;for(let t of o.children)ue(t,s,e+1)}function j(o,s){o.collapsed=s;for(let e of o.children)j(e,s)}function R(o,s){return o.kind==="heading"&&s<5?"heading":"list"}function y(o,s){if(o.id===s)return o;for(let e of o.children){let t=y(e,s);if(t)return t}return null}function Z(o,s){let e=Be(s);if(N(o.text)===e)return o;for(let t of o.children){let i=Z(t,e);if(i)return i}return null}function b(o,s){for(let e of o.children){if(e.id===s)return o;let t=b(e,s);if(t)return t}return null}function ge(o,s){o.children=o.children.filter(e=>!s.has(e.id));for(let e of o.children)ge(e,s)}function B(o){let s=[],e=t=>{s.push(t);for(let i of t.children)e(i)};return e(o),s}function fe(o,s){if(o.id===s)return[o];for(let e of o.children){let t=fe(e,s);if(t.length>0)return[o,...t]}return[]}function $e(o){if(!o)return[];let s=[],e=t=>{for(let i of t.children)s.push(i.id),e(i)};return e(o),s}function Ae(o,s,e){if(s===o.id||s===e.parentId)return!1;let t=b(o,s),i=y(o,e.parentId);if(!t||!i)return!1;let n=t.children.findIndex(a=>a.id===s);if(n===-1)return!1;let[r]=t.children.splice(n,1);if(!r)return!1;let d=e.mode==="child"?i.children.length:e.index;return t.id===i.id&&na.id===s);if(n===-1)return!1;if(e==="up")return n===0?!1:([t.children[n-1],t.children[n]]=[t.children[n],t.children[n-1]],!0);if(e==="down")return n>=t.children.length-1?!1:([t.children[n],t.children[n+1]]=[t.children[n+1],t.children[n]],!0);if(e==="indent"){if(n===0)return!1;let a=t.children[n-1];return t.children.splice(n,1),i.kind=R(a,I(o,a.id)),a.children.push(i),a.collapsed=!1,!0}let r=b(o,t.id);if(!r)return!1;t.children.splice(n,1);let d=r.children.findIndex(a=>a.id===t.id);return i.kind=R(r,I(o,r.id)),r.children.splice(d+1,0,i),!0}function Be(o){return decodeURIComponent(o).replace(/^#+/,"").trim().toLowerCase().replace(/\s+/g,"-")}function N(o){return de(o).trim().toLowerCase().replace(/[^\p{Letter}\p{Number}\p{Emoji_Presentation}\s-]/gu,"").replace(/\s+/g,"-")}function I(o,s,e=0){if(o.id===s)return e;for(let t of o.children){let i=I(t,s,e+1);if(i!==-1)return i}return-1}function ee(o){let s=Math.min(...o.map(n=>n.x)),e=Math.min(...o.map(n=>n.y)),t=Math.max(...o.map(n=>n.x+n.width)),i=Math.max(...o.map(n=>n.y+n.height));return{minX:s,minY:e,width:t-s,height:i-e}}function D(o,s,e){return Math.max(s,Math.min(e,o))}function U(o){return document.createElementNS("http://www.w3.org/2000/svg",o)} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..f166250 --- /dev/null +++ b/main.ts @@ -0,0 +1,2711 @@ +import { + App, + ItemView, + Menu, + MarkdownView, + Modal, + Notice, + Plugin, + PluginSettingTab, + Setting, + SuggestModal, + TFile, + ViewStateResult, + WorkspaceLeaf, + setIcon +} from "obsidian"; + +const VIEW_TYPE_ONEMIND = "onemind-view"; + +type NodeKind = "heading" | "list"; +type LayoutDirection = "right" | "balanced"; +type OneMindLanguage = "en" | "zh"; + +interface MindNode { + id: string; + text: string; + kind: NodeKind; + children: MindNode[]; + collapsed: boolean; +} + +interface PositionedNode { + node: MindNode; + parent: MindNode | null; + depth: number; + x: number; + y: number; + width: number; + height: number; + color: string; + side: -1 | 1; +} + +interface OneMindSettings { + autoSaveDelay: number; + branchColors: string[]; + defaultExpandDepth: number; + animations: boolean; + layoutDirection: LayoutDirection; + exportScale: number; + exportTransparentBackground: boolean; + language: OneMindLanguage; + showAssociationLinks: boolean; +} + +interface MindDocument { + root: MindNode; + prefix: string; +} + +interface OneMindViewState { + [key: string]: unknown; + file?: string; + node?: string; + heading?: string; +} + +interface ParsedEmojiText { + emoji: string | null; + label: string; +} + +interface ParsedNodeText extends ParsedEmojiText { + tags: string[]; + displayLabel: string; +} + +const EMOJI_OPTIONS = [ + "💡", "✅", "🔥", "⭐", "🎯", "🚀", "📌", "📎", + "🧠", "📝", "📚", "🔍", "⚡", "❤️", "❓", "⚠️", + "🌱", "🏁", "🔧", "💬", "🎨", "🧩", "📊", "🔗" +]; + +const DEFAULT_SETTINGS: OneMindSettings = { + autoSaveDelay: 300, + branchColors: ["#5b8def", "#45b36b", "#e89b3c", "#9b72e7", "#df6f9f"], + defaultExpandDepth: 99, + animations: true, + layoutDirection: "right", + exportScale: 2, + exportTransparentBackground: false, + language: "zh", + showAssociationLinks: true +}; + +const I18N = { + en: { + openRibbon: "Open OneMind", + openMindMap: "Open current note as mind map", + returnMarkdown: "Return to Markdown editor", + addChild: "Add child node", + addSibling: "Add sibling node", + duplicateNode: "Duplicate selected node", + moveUp: "Move selected node up", + moveDown: "Move selected node down", + indentNode: "Indent selected node", + outdentNode: "Outdent selected node", + editNode: "Edit selected node", + deleteNode: "Delete selected node", + toggleNode: "Toggle selected node", + setEmoji: "Set selected node emoji", + clearEmoji: "Clear selected node emoji", + addTag: "Add tag to selected nodes", + clearTags: "Clear tags from selected nodes", + copyBranch: "Copy selected branch as Markdown", + pasteChild: "Paste Markdown as child nodes", + pasteSibling: "Paste Markdown as sibling nodes", + selectAll: "Select all nodes", + expandAll: "Expand all nodes", + collapseAll: "Collapse all nodes", + fitMap: "Fit mind map to view", + focusNode: "Focus selected node", + searchNodes: "Search nodes", + filterByTag: "Filter by tag", + clearTagFilter: "Clear tag filter", + toggleOutline: "Toggle outline", + copyNodeLink: "Copy selected node link", + exportSvg: "Export mind map as SVG", + exportPng: "Export mind map as PNG", + exportSelectedSvg: "Export selected branch as SVG", + exportSelectedPng: "Export selected branch as PNG", + markdown: "Markdown", + addChildShort: "Add child", + addSiblingShort: "Add sibling", + duplicateShort: "Duplicate", + setEmojiIcon: "Set emoji icon", + addTagShort: "Add tag", + deleteShort: "Delete", + fitShort: "Fit", + exportSvgShort: "Export SVG", + exportPngShort: "Export PNG", + copyLinkShort: "Copy node link", + outline: "Outline", + hideOutline: "Hide outline", + openMarkdownFirst: "Open a Markdown note first.", + emptyView: "Open a Markdown note to use OneMind.", + reloadedExternal: "OneMind reloaded the external Markdown changes.", + keptLocal: "OneMind kept the mind map edits and wrote them to Markdown.", + exported: "Exported", + copiedLink: "Copied OneMind node link.", + copiedBranch: "Copied branch Markdown.", + pastedMarkdown: "Pasted Markdown into OneMind.", + clipboardEmpty: "Clipboard does not contain Markdown nodes.", + tagFilterActive: "Tag filter", + chooseEmoji: "Choose an emoji icon", + enterTag: "Enter tag name", + conflictTitle: "OneMind conflict", + conflictBody: "This note changed on disk while the mind map has unsaved local edits. Choose which version should win.", + reloadMarkdown: "Reload Markdown", + keepEdits: "Keep OneMind edits", + settingsTitle: "OneMind", + language: "Language", + languageDesc: "Choose the language used by OneMind UI.", + english: "English", + chinese: "中文", + autoSaveDelay: "Auto-save delay", + autoSaveDelayDesc: "Milliseconds to wait after a mind map edit before writing Markdown.", + defaultExpandDepth: "Default expand depth", + defaultExpandDepthDesc: "How many levels are expanded when a note opens. Use 99 for all.", + layoutDirection: "Layout direction", + layoutDirectionDesc: "Choose a classic right-facing tree or a balanced map with branches on both sides.", + horizontalRight: "Horizontal right", + balanced: "Balanced", + layoutAnimation: "Layout animation", + layoutAnimationDesc: "Animate node movement after edits.", + branchColors: "Branch colors", + branchColorsDesc: "Comma-separated CSS colors used for first-level branches.", + resetBranchColors: "Reset branch colors", + pngScale: "PNG export scale", + pngScaleDesc: "Resolution multiplier used when exporting PNG files.", + transparentPng: "Transparent PNG background", + transparentPngDesc: "Leave PNG exports transparent instead of filling the Obsidian background color.", + showAssociationLinks: "Show association links", + showAssociationLinksDesc: "Draw dashed cross-branch links for local wikilinks like [[#Heading]] or [[Heading]].", + saved: "Saved", + unsaved: "Unsaved", + saving: "Saving", + nodesLabel: "nodes", + selectedLabel: "selected", + zoomLabel: "zoom" + }, + zh: { + openRibbon: "打开 OneMind", + openMindMap: "以思维导图打开当前笔记", + returnMarkdown: "返回 Markdown 编辑器", + addChild: "添加子节点", + addSibling: "添加同级节点", + duplicateNode: "复制选中节点", + moveUp: "上移选中节点", + moveDown: "下移选中节点", + indentNode: "缩进选中节点", + outdentNode: "取消缩进选中节点", + editNode: "编辑选中节点", + deleteNode: "删除选中节点", + toggleNode: "折叠/展开选中节点", + setEmoji: "设置选中节点 Emoji", + clearEmoji: "清除选中节点 Emoji", + addTag: "给选中节点添加标签", + clearTags: "清除选中节点标签", + copyBranch: "复制选中分支为 Markdown", + pasteChild: "粘贴 Markdown 为子节点", + pasteSibling: "粘贴 Markdown 为同级节点", + selectAll: "选择全部节点", + expandAll: "展开全部节点", + collapseAll: "折叠全部节点", + fitMap: "适应窗口", + focusNode: "聚焦选中节点", + searchNodes: "搜索节点", + filterByTag: "按标签过滤", + clearTagFilter: "清除标签过滤", + toggleOutline: "显示/隐藏大纲", + copyNodeLink: "复制选中节点链接", + exportSvg: "导出思维导图为 SVG", + exportPng: "导出思维导图为 PNG", + exportSelectedSvg: "导出选中分支为 SVG", + exportSelectedPng: "导出选中分支为 PNG", + markdown: "Markdown", + addChildShort: "添加子节点", + addSiblingShort: "添加同级", + duplicateShort: "复制节点", + setEmojiIcon: "设置 Emoji 图标", + addTagShort: "添加标签", + deleteShort: "删除", + fitShort: "适应窗口", + exportSvgShort: "导出 SVG", + exportPngShort: "导出 PNG", + copyLinkShort: "复制节点链接", + outline: "大纲", + hideOutline: "隐藏大纲", + openMarkdownFirst: "请先打开一篇 Markdown 笔记。", + emptyView: "打开 Markdown 笔记后即可使用 OneMind。", + reloadedExternal: "OneMind 已重新加载外部 Markdown 修改。", + keptLocal: "OneMind 已保留导图编辑并写回 Markdown。", + exported: "已导出", + copiedLink: "已复制 OneMind 节点链接。", + copiedBranch: "已复制分支 Markdown。", + pastedMarkdown: "已将 Markdown 粘贴到 OneMind。", + clipboardEmpty: "剪贴板里没有可用的 Markdown 节点。", + tagFilterActive: "标签过滤", + chooseEmoji: "选择 Emoji 图标", + enterTag: "输入标签名", + conflictTitle: "OneMind 冲突", + conflictBody: "这篇笔记在磁盘上发生了变化,同时思维导图里还有未保存编辑。请选择要保留的版本。", + reloadMarkdown: "重新加载 Markdown", + keepEdits: "保留 OneMind 编辑", + settingsTitle: "OneMind", + language: "语言", + languageDesc: "选择 OneMind 界面语言。", + english: "English", + chinese: "中文", + autoSaveDelay: "自动保存延迟", + autoSaveDelayDesc: "导图编辑后等待多少毫秒再写回 Markdown。", + defaultExpandDepth: "默认展开深度", + defaultExpandDepthDesc: "打开笔记时默认展开的层级。99 表示全部展开。", + layoutDirection: "布局方向", + layoutDirectionDesc: "选择经典向右树形布局,或根节点居中的双向平衡布局。", + horizontalRight: "水平向右", + balanced: "双向平衡", + layoutAnimation: "布局动画", + layoutAnimationDesc: "编辑后为节点移动添加动画。", + branchColors: "分支颜色", + branchColorsDesc: "一级分支使用的 CSS 颜色,使用英文逗号分隔。", + resetBranchColors: "重置分支颜色", + pngScale: "PNG 导出倍率", + pngScaleDesc: "导出 PNG 文件时使用的分辨率倍率。", + transparentPng: "透明 PNG 背景", + transparentPngDesc: "导出 PNG 时保留透明背景,而不是填充 Obsidian 背景色。", + showAssociationLinks: "显示关联线", + showAssociationLinksDesc: "为 [[#标题]] 或 [[标题]] 这类本地 wikilink 绘制跨分支虚线关联。", + saved: "已保存", + unsaved: "未保存", + saving: "保存中", + nodesLabel: "节点", + selectedLabel: "已选", + zoomLabel: "缩放" + } +} as const; + +type I18nKey = keyof typeof I18N.en; + +const NODE_WIDTH = 180; +const NODE_HEIGHT = 42; +const LEVEL_GAP = 230; +const ROW_GAP = 18; +const DRAG_THRESHOLD = 4; + +interface DragState { + nodeId: string; + pointerId: number; + startClientX: number; + startClientY: number; + offsetX: number; + offsetY: number; + originalX: number; + originalY: number; + dragging: boolean; + el: HTMLElement; +} + +interface DropTarget { + mode: "child" | "sibling"; + parentId: string; + index: number; + targetId: string; +} + +export default class OneMindPlugin extends Plugin { + settings: OneMindSettings = DEFAULT_SETTINGS; + + async onload(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + + this.registerView( + VIEW_TYPE_ONEMIND, + (leaf) => new OneMindView(leaf, this) + ); + + this.addRibbonIcon("git-fork", this.t("openRibbon"), () => { + void this.openMindMap(); + }); + + this.addCommand({ + id: "open-onemind-view", + name: this.t("openMindMap"), + checkCallback: (checking) => { + const file = this.getActiveMarkdownFile(); + if (!file) return false; + if (!checking) void this.openMindMap(file, this.getActiveHeadingAnchor()); + return true; + } + }); + + this.addCommand({ + id: "return-to-markdown", + name: this.t("returnMarkdown"), + callback: () => { + const view = this.app.workspace.getActiveViewOfType(OneMindView); + if (view) void view.openMarkdown(); + } + }); + + this.addMindMapCommand("add-child-node", this.t("addChild"), (view) => view.addChildNode()); + this.addMindMapCommand("add-sibling-node", this.t("addSibling"), (view) => view.addSiblingNode()); + this.addMindMapCommand("duplicate-node", this.t("duplicateNode"), (view) => view.duplicateSelectedNode()); + this.addMindMapCommand("move-node-up", this.t("moveUp"), (view) => view.moveSelectedNode("up")); + this.addMindMapCommand("move-node-down", this.t("moveDown"), (view) => view.moveSelectedNode("down")); + this.addMindMapCommand("indent-node", this.t("indentNode"), (view) => view.moveSelectedNode("indent")); + this.addMindMapCommand("outdent-node", this.t("outdentNode"), (view) => view.moveSelectedNode("outdent")); + this.addMindMapCommand("edit-selected-node", this.t("editNode"), (view) => view.editSelectedNode()); + this.addMindMapCommand("delete-selected-node", this.t("deleteNode"), (view) => view.deleteSelectedNode()); + this.addMindMapCommand("toggle-selected-node", this.t("toggleNode"), (view) => view.toggleSelectedNode()); + this.addMindMapCommand("set-node-emoji", this.t("setEmoji"), (view) => view.openEmojiPicker()); + this.addMindMapCommand("clear-node-emoji", this.t("clearEmoji"), (view) => view.clearSelectedEmoji()); + this.addMindMapCommand("add-node-tag", this.t("addTag"), (view) => view.openTagModal()); + this.addMindMapCommand("clear-node-tags", this.t("clearTags"), (view) => view.clearSelectedTags()); + this.addMindMapCommand("select-all-nodes", this.t("selectAll"), (view) => view.selectAllNodes()); + this.addMindMapCommand("expand-all-nodes", this.t("expandAll"), (view) => view.expandAllNodes()); + this.addMindMapCommand("collapse-all-nodes", this.t("collapseAll"), (view) => view.collapseAllNodes()); + this.addMindMapCommand("fit-mind-map", this.t("fitMap"), (view) => view.fitToView()); + this.addMindMapCommand("focus-selected-node", this.t("focusNode"), (view) => view.focusSelected()); + this.addMindMapCommand("search-nodes", this.t("searchNodes"), (view) => view.focusSearch()); + this.addMindMapCommand("filter-by-tag", this.t("filterByTag"), (view) => view.openTagFilterModal()); + this.addMindMapCommand("clear-tag-filter", this.t("clearTagFilter"), (view) => view.clearTagFilter()); + this.addMindMapCommand("toggle-outline", this.t("toggleOutline"), (view) => view.toggleOutline()); + this.addMindMapCommand("copy-node-link", this.t("copyNodeLink"), (view) => void view.copySelectedNodeLink()); + this.addMindMapCommand("copy-branch-markdown", this.t("copyBranch"), (view) => void view.copySelectedBranchMarkdown()); + this.addMindMapCommand("paste-markdown-child", this.t("pasteChild"), (view) => void view.pasteMarkdownAsChild()); + this.addMindMapCommand("paste-markdown-sibling", this.t("pasteSibling"), (view) => void view.pasteMarkdownAsSibling()); + this.addMindMapCommand("export-svg", this.t("exportSvg"), (view) => void view.exportSvg()); + this.addMindMapCommand("export-png", this.t("exportPng"), (view) => void view.exportPng()); + this.addMindMapCommand("export-selected-svg", this.t("exportSelectedSvg"), (view) => void view.exportSelectedSvg()); + this.addMindMapCommand("export-selected-png", this.t("exportSelectedPng"), (view) => void view.exportSelectedPng()); + + this.addSettingTab(new OneMindSettingTab(this.app, this)); + } + + onunload(): void { + this.app.workspace.detachLeavesOfType(VIEW_TYPE_ONEMIND); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + t(key: I18nKey): string { + return I18N[this.settings.language]?.[key] ?? I18N.en[key]; + } + + refreshOpenViews(): void { + this.app.workspace.getLeavesOfType(VIEW_TYPE_ONEMIND).forEach((leaf) => { + const view = leaf.view; + if (view instanceof OneMindView) view.applySettings(); + }); + } + + private addMindMapCommand(id: string, name: string, action: (view: OneMindView) => void): void { + this.addCommand({ + id, + name, + checkCallback: (checking) => { + const view = this.app.workspace.getActiveViewOfType(OneMindView); + if (!view) return false; + if (!checking) action(view); + return true; + } + }); + } + + private getActiveMarkdownFile(): TFile | null { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + return view?.file ?? null; + } + + private getActiveHeadingAnchor(): string | null { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view) return null; + const cursor = view.editor.getCursor(); + for (let line = cursor.line; line >= 0; line -= 1) { + const match = /^(#{1,6})\s+(.+?)\s*$/.exec(view.editor.getLine(line)); + if (match) return slugifyNodeText(match[2]); + } + return null; + } + + async openMindMap(file = this.getActiveMarkdownFile(), heading: string | null = null): Promise { + if (!file) { + new Notice(this.t("openMarkdownFirst")); + return; + } + + const leaf = this.app.workspace.getLeaf(false); + await leaf.setViewState({ + type: VIEW_TYPE_ONEMIND, + state: { file: file.path, heading: heading ?? undefined }, + active: true + }); + } +} + +class OneMindView extends ItemView { + private plugin: OneMindPlugin; + private file: TFile | null = null; + private root: MindNode | null = null; + private canvasEl!: HTMLDivElement; + private sceneEl!: HTMLDivElement; + private svgEl!: SVGSVGElement; + private nodesEl!: HTMLDivElement; + private outlineEl!: HTMLDivElement; + private outlineListEl!: HTMLDivElement; + private statusEl!: HTMLDivElement; + private searchInputEl!: HTMLInputElement; + private searchCountEl!: HTMLSpanElement; + private selectedId: string | null = null; + private selectedIds = new Set(); + private positions: PositionedNode[] = []; + private searchQuery = ""; + private searchMatches: string[] = []; + private activeSearchIndex = -1; + private saveTimer: number | null = null; + private isSaving = false; + private history: string[] = []; + private future: string[] = []; + private scale = 1; + private panX = 80; + private panY = 80; + private panning: { x: number; y: number; panX: number; panY: number } | null = null; + private dragState: DragState | null = null; + private longPressTimer: number | null = null; + private dropTarget: DropTarget | null = null; + private dragMarkerEl: HTMLDivElement | null = null; + private lastSavedMarkdown = ""; + private hasLocalUnsavedChange = false; + private documentPrefix = ""; + private conflictModalOpen = false; + private pendingFocus: { node?: string; heading?: string } | null = null; + private outlineVisible = true; + private tagFilter: string | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: OneMindPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return VIEW_TYPE_ONEMIND; + } + + getDisplayText(): string { + return this.file ? `OneMind: ${this.file.basename}` : "OneMind"; + } + + getIcon(): string { + return "git-fork"; + } + + async setState(state: OneMindViewState, result: ViewStateResult): Promise { + await super.setState(state, result); + this.file = state.file ? this.app.vault.getAbstractFileByPath(state.file) as TFile : null; + this.pendingFocus = { node: state.node, heading: state.heading }; + await this.loadFile(); + } + + getState(): OneMindViewState { + return { file: this.file?.path ?? "", node: this.selectedId ?? undefined }; + } + + async onOpen(): Promise { + this.contentEl.empty(); + this.contentEl.addClass("onemind-view"); + this.contentEl.toggleClass("is-animation-disabled", !this.plugin.settings.animations); + + const toolbar = this.contentEl.createDiv({ cls: "onemind-toolbar" }); + this.addIconButton(toolbar, "file-text", this.plugin.t("markdown"), () => void this.openMarkdown()); + this.addIconButton(toolbar, "plus", this.plugin.t("addChildShort"), () => this.addChildNode()); + this.addIconButton(toolbar, "corner-down-right", this.plugin.t("addSiblingShort"), () => this.addSiblingNode()); + this.addIconButton(toolbar, "copy-plus", this.plugin.t("duplicateShort"), () => this.duplicateSelectedNode()); + this.addIconButton(toolbar, "smile", this.plugin.t("setEmojiIcon"), () => this.openEmojiPicker()); + this.addIconButton(toolbar, "tag", this.plugin.t("addTagShort"), () => this.openTagModal()); + this.addIconButton(toolbar, "filter", this.plugin.t("filterByTag"), () => this.openTagFilterModal()); + this.addIconButton(toolbar, "trash-2", this.plugin.t("deleteShort"), () => this.deleteSelectedNode()); + this.addIconButton(toolbar, "unfold-vertical", this.plugin.t("expandAll"), () => this.expandAllNodes()); + this.addIconButton(toolbar, "fold-vertical", this.plugin.t("collapseAll"), () => this.collapseAllNodes()); + this.addIconButton(toolbar, "minimize", this.plugin.t("fitShort"), () => this.fitToView()); + this.addIconButton(toolbar, "download", this.plugin.t("exportSvgShort"), () => void this.exportSvg()); + this.addIconButton(toolbar, "image", this.plugin.t("exportPngShort"), () => void this.exportPng()); + this.addIconButton(toolbar, "link", this.plugin.t("copyLinkShort"), () => void this.copySelectedNodeLink()); + this.addIconButton(toolbar, "copy", this.plugin.t("copyBranch"), () => void this.copySelectedBranchMarkdown()); + this.addIconButton(toolbar, "list-tree", this.plugin.t("toggleOutline"), () => this.toggleOutline()); + this.addIconButton(toolbar, "search", this.plugin.t("searchNodes"), () => this.searchInputEl.focus()); + const searchWrap = toolbar.createDiv({ cls: "onemind-search" }); + this.searchInputEl = searchWrap.createEl("input", { + cls: "onemind-search-input", + attr: { type: "search", placeholder: this.plugin.t("searchNodes"), "aria-label": this.plugin.t("searchNodes") } + }); + this.searchCountEl = searchWrap.createEl("span", { cls: "onemind-search-count" }); + this.searchInputEl.addEventListener("input", () => this.updateSearch(this.searchInputEl.value)); + this.searchInputEl.addEventListener("keydown", (event) => this.onSearchKeyDown(event)); + + this.canvasEl = this.contentEl.createDiv({ cls: "onemind-canvas" }); + this.sceneEl = this.canvasEl.createDiv({ cls: "onemind-scene" }); + this.svgEl = createSvg("svg"); + this.svgEl.addClass("onemind-links"); + this.nodesEl = this.sceneEl.createDiv({ cls: "onemind-nodes" }); + this.sceneEl.prepend(this.svgEl); + this.outlineEl = this.canvasEl.createDiv({ cls: "onemind-outline" }); + const outlineHeader = this.outlineEl.createDiv({ cls: "onemind-outline-header" }); + outlineHeader.createSpan({ text: this.plugin.t("outline") }); + this.addIconButton(outlineHeader, "x", this.plugin.t("hideOutline"), () => this.toggleOutline(false)); + this.outlineListEl = this.outlineEl.createDiv({ cls: "onemind-outline-list" }); + this.statusEl = this.canvasEl.createDiv({ cls: "onemind-status" }); + + this.registerDomEvent(this.canvasEl, "wheel", (event) => this.onWheel(event)); + this.registerDomEvent(this.canvasEl, "pointerdown", (event) => this.onPointerDown(event)); + this.registerDomEvent(window, "pointermove", (event) => this.onPointerMove(event)); + this.registerDomEvent(window, "pointerup", (event) => this.onPointerUp(event)); + this.registerDomEvent(this.contentEl, "keydown", (event) => this.onKeyDown(event)); + + this.registerEvent(this.app.vault.on("modify", async (file) => { + if (!this.file || file.path !== this.file.path || this.isSaving) return; + if (this.hasLocalUnsavedChange) { + this.showConflictModal(); + return; + } + void this.loadFile(); + })); + + await this.loadFile(); + } + + async openMarkdown(): Promise { + if (!this.file) return; + await this.leaf.setViewState({ + type: "markdown", + state: { file: this.file.path }, + active: true + }); + } + + applySettings(): void { + this.contentEl.toggleClass("is-animation-disabled", !this.plugin.settings.animations); + this.render(); + } + + private addIconButton(parent: HTMLElement, icon: string, title: string, action: () => void): void { + const button = parent.createEl("button", { cls: "clickable-icon onemind-tool-button", attr: { "aria-label": title, title } }); + setIcon(button, icon); + button.onClickEvent(action); + } + + private async loadFile(): Promise { + if (!this.file || !(this.file instanceof TFile)) { + this.renderEmpty(this.plugin.t("emptyView")); + return; + } + + const source = await this.app.vault.read(this.file); + const document = parseMindDocument(source, this.file.basename); + this.root = document.root; + this.documentPrefix = document.prefix; + applyDefaultExpandDepth(this.root, this.plugin.settings.defaultExpandDepth); + this.lastSavedMarkdown = source; + this.hasLocalUnsavedChange = false; + this.history = []; + this.future = []; + this.selectedId = this.selectedId && findNode(this.root, this.selectedId) ? this.selectedId : this.root.id; + this.selectedIds = new Set([this.selectedId]); + this.render(); + this.applyPendingFocus(); + } + + private renderEmpty(message: string): void { + this.root = null; + this.nodesEl?.empty(); + this.svgEl?.empty(); + this.contentEl.createDiv({ cls: "onemind-empty", text: message }); + } + + private render(): void { + if (!this.root) return; + const visibleRoot = this.tagFilter ? filterTreeByTag(this.root, this.tagFilter) : this.root; + this.positions = visibleRoot ? layoutTree(visibleRoot, this.plugin.settings.branchColors, this.plugin.settings.layoutDirection) : []; + this.refreshSearchMatches(); + this.nodesEl.empty(); + this.svgEl.empty(); + this.dragMarkerEl = null; + + if (this.positions.length === 0) { + this.renderOutline(); + this.applyTransform(); + return; + } + + const bounds = getBounds(this.positions); + this.svgEl.setAttrs({ + width: `${Math.max(1, bounds.width + 300)}`, + height: `${Math.max(1, bounds.height + 180)}`, + viewBox: `${bounds.minX - 80} ${bounds.minY - 80} ${bounds.width + 160} ${bounds.height + 160}` + }); + + for (const item of this.positions) { + if (item.parent) this.drawLink(item); + } + + if (this.plugin.settings.showAssociationLinks) { + this.drawAssociationLinks(); + } + + for (const item of this.positions) { + this.renderNode(item); + } + + this.renderOutline(); + this.updateStatus(); + this.applyTransform(); + } + + private renderOutline(): void { + if (!this.root || !this.outlineListEl) return; + this.outlineEl.toggleClass("is-hidden", !this.outlineVisible); + this.outlineListEl.empty(); + if (this.tagFilter) { + const filterRow = this.outlineListEl.createDiv({ cls: "onemind-outline-filter" }); + filterRow.createSpan({ text: `${this.plugin.t("tagFilterActive")}: #${this.tagFilter}` }); + const clearButton = filterRow.createEl("button", { text: "x" }); + clearButton.onClickEvent(() => this.clearTagFilter()); + } + + const visit = (node: MindNode, depth: number): void => { + if (this.tagFilter && !subtreeHasTag(node, this.tagFilter)) return; + const row = this.outlineListEl.createDiv({ cls: "onemind-outline-row" }); + row.toggleClass("is-selected", this.selectedIds.has(node.id)); + row.style.paddingLeft = `${8 + depth * 14}px`; + const parsed = parseNodeText(node.text); + if (parsed.emoji) row.createSpan({ cls: "onemind-outline-emoji", text: parsed.emoji }); + row.createSpan({ cls: "onemind-outline-label", text: stripMarkdownFormatting(parsed.displayLabel) }); + if (node.collapsed && node.children.length > 0) row.createSpan({ cls: "onemind-outline-count", text: `${node.children.length}` }); + row.onClickEvent((event) => { + event.stopPropagation(); + this.selectAndFocusNode(node.id); + }); + if (!node.collapsed) { + for (const child of node.children) visit(child, depth + 1); + } + }; + + visit(this.root, 0); + } + + private renderNode(item: PositionedNode): void { + const el = this.nodesEl.createDiv({ cls: "onemind-node" }); + el.toggleClass("is-root", item.depth === 0); + el.toggleClass("is-selected", this.selectedIds.has(item.node.id)); + el.toggleClass("is-primary-selected", item.node.id === this.selectedId); + el.toggleClass("is-search-match", this.searchMatches.includes(item.node.id)); + el.toggleClass("is-active-search-match", this.activeSearchIndex >= 0 && this.searchMatches[this.activeSearchIndex] === item.node.id); + el.toggleClass("is-collapsed", item.node.collapsed); + el.setAttr("data-node-id", item.node.id); + el.setAttr("data-node-anchor", slugifyNodeText(item.node.text)); + el.setAttr("tabindex", "0"); + el.style.left = `${item.x}px`; + el.style.top = `${item.y}px`; + el.style.width = `${item.width}px`; + el.style.borderColor = item.color; + + const parsedText = parseNodeText(item.node.text); + if (parsedText.emoji) { + el.createDiv({ cls: "onemind-node-emoji", text: parsedText.emoji }); + el.addClass("has-emoji"); + } + + const content = el.createDiv({ cls: "onemind-node-content" }); + renderInlineMarkdown(content, parsedText.displayLabel, this.app); + + if (parsedText.tags.length > 0) { + const tagsEl = el.createDiv({ cls: "onemind-node-tags" }); + for (const tag of parsedText.tags) { + const tagEl = tagsEl.createSpan({ cls: "onemind-node-tag", text: `#${tag}` }); + tagEl.toggleClass("is-active", this.tagFilter === tag); + tagEl.onClickEvent((event) => { + event.stopPropagation(); + this.setTagFilter(tag); + }); + } + } + + if (item.node.children.length > 0) { + const toggle = el.createDiv({ cls: "onemind-collapse" }); + setIcon(toggle, item.node.collapsed ? "chevron-right" : "chevron-down"); + toggle.onClickEvent((event) => { + event.stopPropagation(); + this.toggleCollapse(item.node.id); + }); + } + + el.onClickEvent((event) => this.handleNodeClick(event, item.node.id)); + el.ondblclick = () => this.editNode(item.node.id); + el.addEventListener("pointerdown", (event) => this.onNodePointerDown(event, item)); + el.addEventListener("pointermove", () => this.clearLongPressTimer()); + el.addEventListener("pointerleave", () => this.clearLongPressTimer()); + el.addEventListener("contextmenu", (event) => this.openNodeMenu(event, item.node.id)); + } + + private openNodeMenu(event: MouseEvent, id: string): void { + event.preventDefault(); + event.stopPropagation(); + if (!this.selectedIds.has(id)) { + this.selectedId = id; + this.selectedIds = new Set([id]); + this.render(); + } + + const menu = new Menu(); + menu.addItem((item) => item.setTitle(this.plugin.t("addChild")).setIcon("plus").onClick(() => this.addChildNode())); + menu.addItem((item) => item.setTitle(this.plugin.t("addSibling")).setIcon("corner-down-right").onClick(() => this.addSiblingNode())); + menu.addItem((item) => item.setTitle(this.plugin.t("editNode")).setIcon("pencil").onClick(() => this.editSelectedNode())); + menu.addSeparator(); + menu.addItem((item) => item.setTitle(this.plugin.t("duplicateNode")).setIcon("copy-plus").onClick(() => this.duplicateSelectedNode())); + menu.addItem((item) => item.setTitle(this.plugin.t("copyBranch")).setIcon("copy").onClick(() => void this.copySelectedBranchMarkdown())); + menu.addItem((item) => item.setTitle(this.plugin.t("copyNodeLink")).setIcon("link").onClick(() => void this.copySelectedNodeLink())); + menu.addSeparator(); + menu.addItem((item) => item.setTitle(this.plugin.t("setEmoji")).setIcon("smile").onClick(() => this.openEmojiPicker())); + menu.addItem((item) => item.setTitle(this.plugin.t("addTag")).setIcon("tag").onClick(() => this.openTagModal())); + menu.addItem((item) => item.setTitle(this.plugin.t("clearTags")).setIcon("tag").onClick(() => this.clearSelectedTags())); + menu.addSeparator(); + menu.addItem((item) => item.setTitle(this.plugin.t("exportSelectedSvg")).setIcon("download").onClick(() => void this.exportSelectedSvg())); + menu.addItem((item) => item.setTitle(this.plugin.t("exportSelectedPng")).setIcon("image").onClick(() => void this.exportSelectedPng())); + menu.addSeparator(); + menu.addItem((item) => item.setTitle(this.plugin.t("deleteNode")).setIcon("trash-2").onClick(() => this.deleteSelectedNode())); + menu.showAtMouseEvent(event); + } + + private openNodeMenuAt(id: string, x: number, y: number): void { + const event = new MouseEvent("contextmenu", { clientX: x, clientY: y, bubbles: true, cancelable: true }); + this.openNodeMenu(event, id); + } + + private drawLink(item: PositionedNode): void { + const parent = this.positions.find((candidate) => candidate.node === item.parent); + if (!parent) return; + const path = createSvg("path"); + const rightward = item.x >= parent.x; + const x1 = rightward ? parent.x + parent.width : parent.x; + const y1 = parent.y + NODE_HEIGHT / 2; + const x2 = rightward ? item.x : item.x + item.width; + const y2 = item.y + NODE_HEIGHT / 2; + const handle = Math.max(60, Math.abs(x2 - x1) * 0.45) * (rightward ? 1 : -1); + path.setAttrs({ + d: `M ${x1} ${y1} C ${x1 + handle} ${y1}, ${x2 - handle} ${y2}, ${x2} ${y2}`, + stroke: item.color, + fill: "none" + }); + this.svgEl.appendChild(path); + } + + private drawAssociationLinks(): void { + const byAnchor = new Map(); + for (const item of this.positions) { + byAnchor.set(slugifyNodeText(item.node.text), item); + } + + const drawn = new Set(); + for (const source of this.positions) { + const anchors = extractLocalWikilinkAnchors(source.node.text, this.file); + for (const anchor of anchors) { + const target = byAnchor.get(anchor); + if (!target || target.node.id === source.node.id) continue; + const key = [source.node.id, target.node.id].sort().join(":"); + if (drawn.has(key)) continue; + drawn.add(key); + this.drawAssociationLink(source, target); + } + } + } + + private drawAssociationLink(source: PositionedNode, target: PositionedNode): void { + const path = createSvg("path"); + path.addClass("onemind-association-link"); + const x1 = source.x + source.width / 2; + const y1 = source.y + NODE_HEIGHT / 2; + const x2 = target.x + target.width / 2; + const y2 = target.y + NODE_HEIGHT / 2; + const midX = (x1 + x2) / 2; + const lift = Math.max(80, Math.abs(y2 - y1) * 0.35); + const midY = Math.min(y1, y2) - lift; + path.setAttrs({ + d: `M ${x1} ${y1} Q ${midX} ${midY}, ${x2} ${y2}`, + fill: "none" + }); + this.svgEl.appendChild(path); + } + + private selectNode(id: string): void { + this.selectedId = id; + this.selectedIds = new Set([id]); + this.render(); + } + + private selectAndFocusNode(id: string): void { + this.selectedId = id; + this.selectedIds = new Set([id]); + this.expandAncestors(id); + this.render(); + this.focusSelected(); + } + + private applyPendingFocus(): void { + if (!this.root || !this.pendingFocus) return; + const target = this.resolveFocusTarget(this.pendingFocus); + this.pendingFocus = null; + if (!target) return; + window.setTimeout(() => this.selectAndFocusNode(target.id), 0); + } + + private resolveFocusTarget(target: { node?: string; heading?: string }): MindNode | null { + if (!this.root) return null; + if (target.node) { + const byId = findNode(this.root, target.node); + if (byId) return byId; + const byNodeAnchor = findNodeByAnchor(this.root, target.node); + if (byNodeAnchor) return byNodeAnchor; + } + if (target.heading) return findNodeByAnchor(this.root, target.heading); + return null; + } + + private handleNodeClick(event: MouseEvent, id: string): void { + if (event.metaKey || event.ctrlKey) { + this.toggleNodeSelection(id); + return; + } + if (event.shiftKey && this.selectedId) { + this.selectRange(this.selectedId, id); + return; + } + this.selectNode(id); + } + + private toggleNodeSelection(id: string): void { + if (this.selectedIds.has(id) && this.selectedIds.size > 1) { + this.selectedIds.delete(id); + if (this.selectedId === id) this.selectedId = this.selectedIds.values().next().value ?? null; + } else { + this.selectedIds.add(id); + this.selectedId = id; + } + this.render(); + } + + private selectRange(fromId: string, toId: string): void { + const ordered = this.positions.map((item) => item.node.id); + const from = ordered.indexOf(fromId); + const to = ordered.indexOf(toId); + if (from === -1 || to === -1) { + this.selectNode(toId); + return; + } + const [start, end] = from < to ? [from, to] : [to, from]; + this.selectedIds = new Set(ordered.slice(start, end + 1)); + this.selectedId = toId; + this.render(); + } + + editSelectedNode(): void { + if (this.selectedId) this.editNode(this.selectedId); + } + + openEmojiPicker(): void { + if (!this.root || !this.selectedId) return; + new EmojiSuggestModal(this.app, this.plugin.t("chooseEmoji"), (emoji) => this.setSelectedEmoji(emoji)).open(); + } + + clearSelectedEmoji(): void { + this.setSelectedEmoji(null); + } + + openTagModal(): void { + if (!this.root || this.selectedIds.size === 0) return; + new TagInputModal(this.app, this.plugin.t("enterTag"), (tag) => this.addTagToSelected(tag)).open(); + } + + openTagFilterModal(): void { + if (!this.root) return; + new TagSuggestModal(this.app, this.getAllTags(), this.plugin.t("filterByTag"), (tag) => this.setTagFilter(tag)).open(); + } + + clearTagFilter(): void { + this.tagFilter = null; + this.render(); + } + + private setTagFilter(tag: string): void { + const normalized = normalizeTag(tag); + if (!normalized) return; + this.tagFilter = normalized; + this.render(); + const first = this.positions.find((item) => parseNodeText(item.node.text).tags.includes(normalized)); + if (first) this.selectAndFocusNode(first.node.id); + } + + private getAllTags(): string[] { + if (!this.root) return []; + return [...new Set(flattenNodes(this.root).flatMap((node) => parseNodeText(node.text).tags))].sort(); + } + + clearSelectedTags(): void { + if (!this.root) return; + const nodes = this.getSelectedNodes(); + if (nodes.length === 0) return; + this.pushHistory(); + for (const node of nodes) { + node.text = removeNodeTags(node.text); + } + this.scheduleSave(); + this.render(); + } + + private addTagToSelected(tag: string): void { + if (!this.root) return; + const normalized = normalizeTag(tag); + if (!normalized) return; + const nodes = this.getSelectedNodes(); + if (nodes.length === 0) return; + this.pushHistory(); + for (const node of nodes) { + node.text = addTagToText(node.text, normalized); + } + this.scheduleSave(); + this.render(); + } + + private setSelectedEmoji(emoji: string | null): void { + if (!this.root) return; + const nodes = this.getSelectedNodes(); + if (nodes.length === 0) return; + this.pushHistory(); + for (const node of nodes) { + const parsed = parseEmojiText(node.text); + node.text = emoji ? `${emoji} ${parsed.label}` : parsed.label; + } + this.scheduleSave(); + this.render(); + } + + private editNode(id: string): void { + if (!this.root) return; + const node = findNode(this.root, id); + const card = this.nodesEl.querySelector(`[data-node-id="${id}"]`); + if (!node || !card) return; + + this.selectedId = id; + card.empty(); + card.addClass("is-editing"); + const input = card.createEl("textarea", { cls: "onemind-editor" }); + input.value = node.text; + input.focus(); + input.select(); + + let committed = false; + const commit = (): void => { + if (committed) return; + committed = true; + const value = input.value.trim() || "Untitled"; + this.pushHistory(); + node.text = value; + this.scheduleSave(); + this.render(); + }; + + input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + commit(); + } + if (event.key === "Escape") { + event.preventDefault(); + this.render(); + } + }); + input.addEventListener("blur", commit, { once: true }); + } + + addChildNode(): void { + if (!this.root) return; + const parent = findNode(this.root, this.selectedId ?? this.root.id) ?? this.root; + this.pushHistory(); + const child = createNode("New idea", nextKind(parent, getNodeDepth(this.root, parent.id))); + parent.children.push(child); + parent.collapsed = false; + this.selectedId = child.id; + this.scheduleSave(); + this.render(); + window.setTimeout(() => this.editNode(child.id), 20); + } + + addSiblingNode(): void { + if (!this.root || !this.selectedId || this.selectedId === this.root.id) { + this.addChildNode(); + return; + } + const parent = findParent(this.root, this.selectedId); + const selected = findNode(this.root, this.selectedId); + if (!parent || !selected) return; + this.pushHistory(); + const index = parent.children.findIndex((child) => child.id === selected.id); + const sibling = createNode("New idea", selected.kind); + parent.children.splice(index + 1, 0, sibling); + this.selectedId = sibling.id; + this.scheduleSave(); + this.render(); + window.setTimeout(() => this.editNode(sibling.id), 20); + } + + duplicateSelectedNode(): void { + if (!this.root || !this.selectedId || this.selectedId === this.root.id) return; + const parent = findParent(this.root, this.selectedId); + const selected = findNode(this.root, this.selectedId); + if (!parent || !selected) return; + this.pushHistory(); + const index = parent.children.findIndex((child) => child.id === selected.id); + const duplicate = cloneNodeWithNewIds(selected); + parent.children.splice(index + 1, 0, duplicate); + this.selectedId = duplicate.id; + this.selectedIds = new Set([duplicate.id]); + this.scheduleSave(); + this.render(); + this.focusSelected(); + } + + moveSelectedNode(direction: "up" | "down" | "indent" | "outdent"): void { + if (!this.root || !this.selectedId || this.selectedId === this.root.id) return; + this.pushHistory(); + const moved = moveNodeByKeyboard(this.root, this.selectedId, direction); + if (!moved) { + this.history.pop(); + return; + } + this.scheduleSave(); + this.render(); + this.focusSelected(); + } + + deleteSelectedNode(): void { + if (!this.root) return; + const ids = this.getEditableSelectedIds(); + if (ids.length === 0) return; + this.pushHistory(); + const fallbackParent = this.selectedId ? findParent(this.root, this.selectedId) : null; + deleteNodes(this.root, new Set(ids)); + this.selectedId = fallbackParent?.id ?? this.root.id; + this.selectedIds = new Set([this.selectedId]); + this.scheduleSave(); + this.render(); + } + + private toggleCollapse(id: string): void { + if (!this.root) return; + const node = findNode(this.root, id); + if (!node) return; + node.collapsed = !node.collapsed; + this.render(); + } + + toggleSelectedNode(): void { + if (!this.root) return; + const nodes = this.getSelectedNodes(); + if (nodes.length === 0) return; + const shouldCollapse = nodes.some((node) => !node.collapsed && node.children.length > 0); + for (const node of nodes) { + if (node.children.length > 0) node.collapsed = shouldCollapse; + } + this.render(); + } + + expandAllNodes(): void { + if (!this.root) return; + setCollapsedRecursive(this.root, false); + this.render(); + } + + collapseAllNodes(): void { + if (!this.root) return; + setCollapsedRecursive(this.root, true); + this.root.collapsed = false; + this.selectedId = this.root.id; + this.render(); + this.focusSelected(); + } + + private scheduleSave(): void { + if (!this.root || !this.file) return; + this.hasLocalUnsavedChange = true; + this.updateStatus(); + if (this.saveTimer) window.clearTimeout(this.saveTimer); + this.saveTimer = window.setTimeout(() => void this.saveNow(), this.plugin.settings.autoSaveDelay); + } + + private showConflictModal(): void { + if (this.conflictModalOpen) return; + this.conflictModalOpen = true; + new ConflictModal( + this.app, + { + title: this.plugin.t("conflictTitle"), + body: this.plugin.t("conflictBody"), + reload: this.plugin.t("reloadMarkdown"), + keep: this.plugin.t("keepEdits") + }, + async () => { + this.conflictModalOpen = false; + await this.loadFile(); + new Notice(this.plugin.t("reloadedExternal")); + }, + async () => { + this.conflictModalOpen = false; + await this.saveNow(); + new Notice(this.plugin.t("keptLocal")); + }, + () => { + this.conflictModalOpen = false; + } + ).open(); + } + + private async saveNow(): Promise { + if (!this.root || !this.file) return; + const markdown = this.serializeCurrentMarkdown(); + this.isSaving = true; + this.updateStatus(); + await this.app.vault.modify(this.file, markdown); + this.lastSavedMarkdown = markdown; + this.hasLocalUnsavedChange = false; + this.isSaving = false; + this.updateStatus(); + } + + private pushHistory(): void { + if (!this.root) return; + this.history.push(this.serializeCurrentMarkdown()); + if (this.history.length > 80) this.history.shift(); + this.future = []; + } + + private undo(): void { + if (!this.root || this.history.length === 0) return; + this.future.push(this.serializeCurrentMarkdown()); + const previous = this.history.pop(); + if (!previous) return; + const document = parseMindDocument(previous, this.file?.basename ?? "Mind map"); + this.root = document.root; + this.documentPrefix = document.prefix; + this.selectedId = this.root.id; + this.selectedIds = new Set([this.root.id]); + this.render(); + void this.saveNow(); + } + + private redo(): void { + if (!this.root) return; + const next = this.future.pop(); + if (!next) return; + this.history.push(this.serializeCurrentMarkdown()); + const document = parseMindDocument(next, this.file?.basename ?? "Mind map"); + this.root = document.root; + this.documentPrefix = document.prefix; + this.selectedId = this.root.id; + this.selectedIds = new Set([this.root.id]); + this.render(); + void this.saveNow(); + } + + private onKeyDown(event: KeyboardEvent): void { + if (event.target instanceof HTMLTextAreaElement) return; + const mod = event.ctrlKey || event.metaKey; + if (mod && event.key.toLowerCase() === "z" && event.shiftKey) { + event.preventDefault(); + this.redo(); + return; + } + if (mod && event.key.toLowerCase() === "z") { + event.preventDefault(); + this.undo(); + return; + } + if (mod && event.key.toLowerCase() === "a") { + event.preventDefault(); + this.selectAllNodes(); + return; + } + if (mod && event.key === "=") { + event.preventDefault(); + this.zoomAtCenter(1.1); + return; + } + if (mod && event.key === "-") { + event.preventDefault(); + this.zoomAtCenter(0.9); + return; + } + if (mod && event.shiftKey && event.key.toLowerCase() === "f") { + event.preventDefault(); + this.fitToView(); + return; + } + if (mod && event.key.toLowerCase() === "f") { + event.preventDefault(); + this.focusSelected(); + return; + } + if (event.altKey && event.key === "ArrowUp") { + event.preventDefault(); + this.moveSelectedNode("up"); + return; + } + if (event.altKey && event.key === "ArrowDown") { + event.preventDefault(); + this.moveSelectedNode("down"); + return; + } + if (event.altKey && event.key === "ArrowRight") { + event.preventDefault(); + this.moveSelectedNode("indent"); + return; + } + if (event.altKey && event.key === "ArrowLeft") { + event.preventDefault(); + this.moveSelectedNode("outdent"); + return; + } + if (event.key === "Tab") { + event.preventDefault(); + this.addChildNode(); + } else if (event.key === "/") { + event.preventDefault(); + this.focusSearch(); + } else if (event.key === "Enter") { + event.preventDefault(); + this.addSiblingNode(); + } else if (event.key === "F2") { + event.preventDefault(); + if (this.selectedId) this.editNode(this.selectedId); + } else if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + this.deleteSelectedNode(); + } else if (event.key === " ") { + event.preventDefault(); + if (this.selectedId) this.toggleCollapse(this.selectedId); + } else if (event.key.startsWith("Arrow")) { + event.preventDefault(); + this.moveSelection(event.key); + } + } + + private onSearchKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter") { + event.preventDefault(); + this.jumpSearch(event.shiftKey ? -1 : 1); + } else if (event.key === "Escape") { + event.preventDefault(); + this.searchInputEl.value = ""; + this.updateSearch(""); + this.contentEl.focus(); + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + this.jumpSearch(event.key === "ArrowDown" ? 1 : -1); + } + } + + focusSearch(): void { + this.searchInputEl.focus(); + this.searchInputEl.select(); + } + + toggleOutline(force?: boolean): void { + this.outlineVisible = force ?? !this.outlineVisible; + this.renderOutline(); + } + + selectAllNodes(): void { + if (!this.root) return; + this.selectedIds = new Set(flattenNodes(this.root).map((node) => node.id)); + this.selectedId = this.root.id; + this.render(); + } + + async exportSvg(): Promise { + if (!this.root || !this.file || this.positions.length === 0) return; + await this.exportSvgFromPositions(this.positions, this.file.basename, this.getExportPath("svg")); + } + + async exportPng(): Promise { + if (!this.root || !this.file || this.positions.length === 0) return; + await this.exportPngFromPositions(this.positions, this.file.basename, this.getExportPath("png")); + } + + async exportSelectedSvg(): Promise { + const branch = this.getSelectedBranchExport(); + if (!branch) return; + await this.exportSvgFromPositions(branch.positions, branch.title, this.getExportPath("svg", branch.slug)); + } + + async exportSelectedPng(): Promise { + const branch = this.getSelectedBranchExport(); + if (!branch) return; + await this.exportPngFromPositions(branch.positions, branch.title, this.getExportPath("png", branch.slug)); + } + + private async exportSvgFromPositions(positions: PositionedNode[], title: string, path: string): Promise { + const svg = renderSvgDocument(positions, title, this.plugin.settings.showAssociationLinks); + await this.writeTextFile(path, svg); + new Notice(`${this.plugin.t("exported")} ${path}`); + } + + private async exportPngFromPositions(positions: PositionedNode[], title: string, path: string): Promise { + const svg = renderSvgDocument(positions, title, this.plugin.settings.showAssociationLinks); + const png = await svgToPng(svg, this.plugin.settings.exportScale, this.plugin.settings.exportTransparentBackground); + const existing = this.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await this.app.vault.modifyBinary(existing, png); + } else { + await this.app.vault.createBinary(path, png); + } + new Notice(`${this.plugin.t("exported")} ${path}`); + } + + private getSelectedBranchExport(): { positions: PositionedNode[]; title: string; slug: string } | null { + if (!this.root || !this.selectedId) return null; + const node = findNode(this.root, this.selectedId); + if (!node) return null; + const branchRoot = cloneNode(node); + setCollapsedRecursive(branchRoot, false); + const positions = layoutTree(branchRoot, this.plugin.settings.branchColors, this.plugin.settings.layoutDirection); + const title = stripInlineMarkdown(node.text); + return { positions, title, slug: slugifyNodeText(node.text) || "branch" }; + } + + async copySelectedNodeLink(): Promise { + if (!this.file || !this.root || !this.selectedId) return; + const node = findNode(this.root, this.selectedId); + if (!node) return; + const vault = this.app.vault.getName(); + const file = this.file.path; + const nodeAnchor = slugifyNodeText(node.text); + const link = `obsidian://open?vault=${encodeURIComponent(vault)}&file=${encodeURIComponent(file)}#${encodeURIComponent(nodeAnchor)}`; + await navigator.clipboard.writeText(link); + new Notice(this.plugin.t("copiedLink")); + } + + async copySelectedBranchMarkdown(): Promise { + if (!this.root || !this.selectedId) return; + const node = findNode(this.root, this.selectedId); + if (!node) return; + await navigator.clipboard.writeText(serializeMarkdown(cloneNode(node))); + new Notice(this.plugin.t("copiedBranch")); + } + + async pasteMarkdownAsChild(): Promise { + if (!this.root) return; + const parent = findNode(this.root, this.selectedId ?? this.root.id) ?? this.root; + const nodes = await this.readClipboardNodes(parent.kind, getNodeDepth(this.root, parent.id) + 1); + if (nodes.length === 0) return; + this.pushHistory(); + parent.children.push(...nodes); + parent.collapsed = false; + this.selectedId = nodes[0].id; + this.selectedIds = new Set([nodes[0].id]); + this.scheduleSave(); + this.render(); + new Notice(this.plugin.t("pastedMarkdown")); + } + + async pasteMarkdownAsSibling(): Promise { + if (!this.root || !this.selectedId || this.selectedId === this.root.id) { + await this.pasteMarkdownAsChild(); + return; + } + const selected = findNode(this.root, this.selectedId); + const parent = findParent(this.root, this.selectedId); + if (!selected || !parent) return; + const nodes = await this.readClipboardNodes(selected.kind, getNodeDepth(this.root, selected.id)); + if (nodes.length === 0) return; + this.pushHistory(); + const index = parent.children.findIndex((child) => child.id === selected.id); + parent.children.splice(index + 1, 0, ...nodes); + this.selectedId = nodes[0].id; + this.selectedIds = new Set([nodes[0].id]); + this.scheduleSave(); + this.render(); + new Notice(this.plugin.t("pastedMarkdown")); + } + + private async readClipboardNodes(kind: NodeKind, depth: number): Promise { + const text = await navigator.clipboard.readText(); + if (!text.trim()) { + new Notice(this.plugin.t("clipboardEmpty")); + return []; + } + const document = parseMindDocument(text, "Clipboard"); + const nodes = document.root.text === "Clipboard" && document.root.children.length > 0 ? document.root.children : [document.root]; + const cloned = nodes.map((node) => cloneNode(node)); + for (const node of cloned) normalizePastedNodeKinds(node, kind, depth); + if (cloned.length === 0) new Notice(this.plugin.t("clipboardEmpty")); + return cloned; + } + + private updateSearch(query: string): void { + this.searchQuery = query.trim().toLowerCase(); + this.refreshSearchMatches(); + this.activeSearchIndex = this.searchMatches.length > 0 ? 0 : -1; + if (this.activeSearchIndex >= 0) { + const id = this.searchMatches[this.activeSearchIndex]; + if (id) this.selectAndFocusNode(id); + } else { + this.render(); + } + this.updateSearchCount(); + } + + private refreshSearchMatches(): void { + if (!this.root || this.searchQuery.length === 0) { + this.searchMatches = []; + this.activeSearchIndex = -1; + this.updateSearchCount(); + return; + } + this.searchMatches = flattenNodes(this.root) + .filter((node) => node.text.toLowerCase().includes(this.searchQuery)) + .map((node) => node.id); + if (this.activeSearchIndex >= this.searchMatches.length) this.activeSearchIndex = this.searchMatches.length - 1; + if (this.searchMatches.length === 0) this.activeSearchIndex = -1; + this.updateSearchCount(); + } + + private updateSearchCount(): void { + if (!this.searchCountEl) return; + if (this.searchQuery.length === 0) { + this.searchCountEl.setText(""); + return; + } + const active = this.activeSearchIndex >= 0 ? this.activeSearchIndex + 1 : 0; + this.searchCountEl.setText(`${active}/${this.searchMatches.length}`); + } + + private jumpSearch(direction: 1 | -1): void { + if (this.searchMatches.length === 0) return; + this.activeSearchIndex = (this.activeSearchIndex + direction + this.searchMatches.length) % this.searchMatches.length; + const id = this.searchMatches[this.activeSearchIndex]; + if (id) this.selectAndFocusNode(id); + this.updateSearchCount(); + } + + private moveSelection(key: string): void { + if (!this.root || !this.selectedId) return; + const current = findNode(this.root, this.selectedId); + if (!current) return; + + if (key === "ArrowRight") { + if (current.collapsed && current.children.length > 0) { + this.toggleCollapse(current.id); + return; + } + const child = current.children[0]; + if (child) this.selectAndFocusNode(child.id); + return; + } + + if (key === "ArrowLeft") { + const parent = findParent(this.root, current.id); + if (current.children.length > 0 && !current.collapsed) { + this.toggleCollapse(current.id); + } else if (parent) { + this.selectAndFocusNode(parent.id); + } + return; + } + + const visible = this.positions.slice().sort((a, b) => a.y - b.y || a.x - b.x); + const index = visible.findIndex((item) => item.node.id === current.id); + const next = visible[index + (key === "ArrowDown" ? 1 : -1)]; + if (next) this.selectAndFocusNode(next.node.id); + } + + private expandAncestors(id: string): void { + if (!this.root) return; + const path = findPath(this.root, id); + for (const node of path.slice(0, -1)) node.collapsed = false; + } + + private getSelectedNodes(): MindNode[] { + if (!this.root) return []; + return [...this.selectedIds] + .map((id) => findNode(this.root as MindNode, id)) + .filter((node): node is MindNode => Boolean(node)); + } + + private getEditableSelectedNodes(): MindNode[] { + if (!this.root) return []; + return this.getSelectedNodes().filter((node) => node.id !== this.root?.id); + } + + private getEditableSelectedIds(): string[] { + return this.getEditableSelectedNodes().map((node) => node.id); + } + + private onWheel(event: WheelEvent): void { + event.preventDefault(); + const factor = event.deltaY > 0 ? 0.92 : 1.08; + this.scale = clamp(this.scale * factor, 0.35, 2.2); + this.applyTransform(); + } + + private onPointerDown(event: PointerEvent): void { + const target = event.target as HTMLElement; + if (target.closest(".onemind-node") || target.closest(".onemind-toolbar")) return; + this.panning = { x: event.clientX, y: event.clientY, panX: this.panX, panY: this.panY }; + this.canvasEl.setPointerCapture(event.pointerId); + } + + private onPointerMove(event: PointerEvent): void { + if (this.dragState) { + this.onNodePointerMove(event); + return; + } + if (!this.panning) return; + this.panX = this.panning.panX + event.clientX - this.panning.x; + this.panY = this.panning.panY + event.clientY - this.panning.y; + this.applyTransform(); + } + + private onPointerUp(event: PointerEvent): void { + if (this.dragState) { + this.finishNodeDrag(event); + return; + } + this.stopPanning(); + } + + private stopPanning(): void { + this.panning = null; + } + + private onNodePointerDown(event: PointerEvent, item: PositionedNode): void { + if (event.button !== 0 || item.node.id === this.root?.id || event.target instanceof HTMLTextAreaElement) return; + const target = event.target as HTMLElement; + if (target.closest(".onemind-collapse") || target.closest(".onemind-wikilink")) return; + event.stopPropagation(); + this.selectedId = item.node.id; + this.selectedIds = new Set([item.node.id]); + this.nodesEl.querySelectorAll(".is-selected").forEach((nodeEl) => nodeEl.removeClass("is-selected")); + (event.currentTarget as HTMLElement).addClass("is-selected"); + const point = this.toScenePoint(event.clientX, event.clientY); + this.dragState = { + nodeId: item.node.id, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + offsetX: point.x - item.x, + offsetY: point.y - item.y, + originalX: item.x, + originalY: item.y, + dragging: false, + el: event.currentTarget as HTMLElement + }; + this.dragState.el.setPointerCapture(event.pointerId); + if (event.pointerType === "touch" || event.pointerType === "pen") { + this.longPressTimer = window.setTimeout(() => { + this.dragState = null; + this.openNodeMenuAt(item.node.id, event.clientX, event.clientY); + }, 560); + } + } + + private onNodePointerMove(event: PointerEvent): void { + if (!this.dragState) return; + this.clearLongPressTimer(); + const dx = event.clientX - this.dragState.startClientX; + const dy = event.clientY - this.dragState.startClientY; + if (!this.dragState.dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + + this.dragState.dragging = true; + this.dragState.el.addClass("is-dragging"); + const point = this.toScenePoint(event.clientX, event.clientY); + const left = point.x - this.dragState.offsetX; + const top = point.y - this.dragState.offsetY; + this.dragState.el.style.left = `${left}px`; + this.dragState.el.style.top = `${top}px`; + this.dropTarget = this.getDropTarget(point.x, point.y, this.dragState.nodeId); + this.renderDropTarget(); + } + + private finishNodeDrag(event: PointerEvent): void { + if (!this.dragState || !this.root) return; + this.clearLongPressTimer(); + const state = this.dragState; + const didDrag = state.dragging; + this.dragState = null; + this.clearDropTarget(); + + try { + state.el.releasePointerCapture(state.pointerId); + } catch { + // Pointer capture may already be released by the host app. + } + + if (!didDrag || !this.dropTarget) { + state.el.style.left = `${state.originalX}px`; + state.el.style.top = `${state.originalY}px`; + state.el.removeClass("is-dragging"); + this.render(); + return; + } + + event.preventDefault(); + this.pushHistory(); + const moved = moveNode(this.root, state.nodeId, this.dropTarget); + if (moved) { + this.selectedId = state.nodeId; + this.scheduleSave(); + } else { + this.history.pop(); + } + this.dropTarget = null; + this.render(); + } + + private getDropTarget(x: number, y: number, draggedId: string): DropTarget | null { + if (!this.root) return null; + const blocked = new Set(getDescendantIds(findNode(this.root, draggedId))); + blocked.add(draggedId); + const candidates = this.positions.filter((item) => !blocked.has(item.node.id)); + const childHit = candidates.find((item) => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height); + if (childHit) { + return { mode: "child", parentId: childHit.node.id, index: childHit.node.children.length, targetId: childHit.node.id }; + } + + let nearest: PositionedNode | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const item of candidates) { + if (item.node.id === this.root.id) continue; + const distance = Math.hypot(x - (item.x + item.width / 2), y - (item.y + item.height / 2)); + if (distance < nearestDistance) { + nearest = item; + nearestDistance = distance; + } + } + if (!nearest || !nearest.parent) return { mode: "child", parentId: this.root.id, index: this.root.children.length, targetId: this.root.id }; + const siblings = nearest.parent.children; + const nearestIndex = siblings.findIndex((child) => child.id === nearest.node.id); + const index = y < nearest.y + nearest.height / 2 ? nearestIndex : nearestIndex + 1; + return { mode: "sibling", parentId: nearest.parent.id, index, targetId: nearest.node.id }; + } + + private renderDropTarget(): void { + this.clearDropTarget(); + if (!this.dropTarget) return; + if (this.dropTarget.mode === "child") { + const targetEl = this.nodesEl.querySelector(`[data-node-id="${this.dropTarget.targetId}"]`); + targetEl?.addClass("is-drop-child"); + return; + } + + const targetPosition = this.positions.find((item) => item.node.id === this.dropTarget?.targetId); + if (!targetPosition) return; + this.dragMarkerEl = this.nodesEl.createDiv({ cls: "onemind-drop-marker" }); + const before = this.dropTarget.index <= (findParent(this.root as MindNode, this.dropTarget.targetId)?.children.findIndex((child) => child.id === this.dropTarget?.targetId) ?? 0); + this.dragMarkerEl.style.left = `${targetPosition.x - 10}px`; + this.dragMarkerEl.style.top = `${before ? targetPosition.y - ROW_GAP / 2 : targetPosition.y + targetPosition.height + ROW_GAP / 2}px`; + this.dragMarkerEl.style.width = `${targetPosition.width + 20}px`; + } + + private clearDropTarget(): void { + this.nodesEl.querySelectorAll(".is-drop-child").forEach((el) => el.removeClass("is-drop-child")); + this.dragMarkerEl?.remove(); + this.dragMarkerEl = null; + } + + private clearLongPressTimer(): void { + if (!this.longPressTimer) return; + window.clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + + private toScenePoint(clientX: number, clientY: number): { x: number; y: number } { + const rect = this.canvasEl.getBoundingClientRect(); + return { + x: (clientX - rect.left - this.panX) / this.scale, + y: (clientY - rect.top - this.panY) / this.scale + }; + } + + private serializeCurrentMarkdown(): string { + if (!this.root) return this.documentPrefix; + return `${this.documentPrefix}${serializeMarkdown(this.root)}`; + } + + private getExportPath(extension: "svg" | "png", suffix?: string): string { + const folder = this.file?.parent?.path && this.file.parent.path !== "/" ? `${this.file.parent.path}/` : ""; + const extra = suffix ? `.${suffix}` : ""; + return `${folder}${this.file?.basename ?? "mind-map"}.onemind${extra}.${extension}`; + } + + private async writeTextFile(path: string, content: string): Promise { + const existing = this.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, content); + } else { + await this.app.vault.create(path, content); + } + } + + private zoomAtCenter(factor: number): void { + this.scale = clamp(this.scale * factor, 0.35, 2.2); + this.applyTransform(); + } + + focusSelected(): void { + const selected = this.positions.find((item) => item.node.id === this.selectedId); + if (!selected) return; + const rect = this.canvasEl.getBoundingClientRect(); + this.panX = rect.width / 2 - (selected.x + selected.width / 2) * this.scale; + this.panY = rect.height / 2 - (selected.y + NODE_HEIGHT / 2) * this.scale; + this.applyTransform(); + } + + fitToView(): void { + if (this.positions.length === 0) return; + const bounds = getBounds(this.positions); + const rect = this.canvasEl.getBoundingClientRect(); + const sx = (rect.width - 120) / Math.max(bounds.width, 1); + const sy = (rect.height - 120) / Math.max(bounds.height, 1); + this.scale = clamp(Math.min(sx, sy), 0.35, 1.4); + this.panX = rect.width / 2 - (bounds.minX + bounds.width / 2) * this.scale; + this.panY = rect.height / 2 - (bounds.minY + bounds.height / 2) * this.scale; + this.applyTransform(); + } + + private applyTransform(): void { + if (!this.sceneEl) return; + this.sceneEl.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.scale})`; + this.updateStatus(); + } + + private updateStatus(): void { + if (!this.statusEl) return; + const total = this.root ? flattenNodes(this.root).length : 0; + const visible = this.positions.length; + const saveState = this.isSaving ? this.plugin.t("saving") : this.hasLocalUnsavedChange ? this.plugin.t("unsaved") : this.plugin.t("saved"); + const filter = this.tagFilter ? `#${this.tagFilter}` : ""; + this.statusEl.setText([ + `${visible}/${total} ${this.plugin.t("nodesLabel")}`, + `${this.selectedIds.size} ${this.plugin.t("selectedLabel")}`, + `${this.plugin.t("zoomLabel")} ${Math.round(this.scale * 100)}%`, + filter, + saveState + ].filter(Boolean).join(" · ")); + } +} + +class OneMindSettingTab extends PluginSettingTab { + plugin: OneMindPlugin; + + constructor(app: App, plugin: OneMindPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: this.plugin.t("settingsTitle") }); + + new Setting(containerEl) + .setName(this.plugin.t("language")) + .setDesc(this.plugin.t("languageDesc")) + .addDropdown((dropdown) => { + dropdown + .addOption("zh", this.plugin.t("chinese")) + .addOption("en", this.plugin.t("english")) + .setValue(this.plugin.settings.language) + .onChange(async (value) => { + this.plugin.settings.language = value as OneMindLanguage; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + this.display(); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("autoSaveDelay")) + .setDesc(this.plugin.t("autoSaveDelayDesc")) + .addText((text) => { + text + .setPlaceholder("300") + .setValue(String(this.plugin.settings.autoSaveDelay)) + .onChange(async (value) => { + const parsed = Number.parseInt(value, 10); + this.plugin.settings.autoSaveDelay = Number.isFinite(parsed) ? clamp(parsed, 0, 5000) : DEFAULT_SETTINGS.autoSaveDelay; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("defaultExpandDepth")) + .setDesc(this.plugin.t("defaultExpandDepthDesc")) + .addSlider((slider) => { + slider + .setLimits(1, 99, 1) + .setDynamicTooltip() + .setValue(this.plugin.settings.defaultExpandDepth) + .onChange(async (value) => { + this.plugin.settings.defaultExpandDepth = value; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("layoutDirection")) + .setDesc(this.plugin.t("layoutDirectionDesc")) + .addDropdown((dropdown) => { + dropdown + .addOption("right", this.plugin.t("horizontalRight")) + .addOption("balanced", this.plugin.t("balanced")) + .setValue(this.plugin.settings.layoutDirection) + .onChange(async (value) => { + this.plugin.settings.layoutDirection = value as LayoutDirection; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("layoutAnimation")) + .setDesc(this.plugin.t("layoutAnimationDesc")) + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.animations) + .onChange(async (value) => { + this.plugin.settings.animations = value; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("showAssociationLinks")) + .setDesc(this.plugin.t("showAssociationLinksDesc")) + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.showAssociationLinks) + .onChange(async (value) => { + this.plugin.settings.showAssociationLinks = value; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("branchColors")) + .setDesc(this.plugin.t("branchColorsDesc")) + .addTextArea((text) => { + text + .setValue(this.plugin.settings.branchColors.join(", ")) + .onChange(async (value) => { + const colors = value.split(",").map((item) => item.trim()).filter(Boolean); + this.plugin.settings.branchColors = colors.length > 0 ? colors : DEFAULT_SETTINGS.branchColors; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + }); + text.inputEl.rows = 2; + }); + + const paletteEl = containerEl.createDiv({ cls: "onemind-settings-palette" }); + this.plugin.settings.branchColors.forEach((color, index) => { + new Setting(paletteEl) + .setName(`${this.plugin.t("branchColors")} ${index + 1}`) + .addColorPicker((picker) => { + picker + .setValue(color) + .onChange(async (value) => { + this.plugin.settings.branchColors[index] = value; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + }); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("resetBranchColors")) + .addButton((button) => { + button + .setButtonText(this.plugin.t("resetBranchColors")) + .onClick(async () => { + this.plugin.settings.branchColors = [...DEFAULT_SETTINGS.branchColors]; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + this.display(); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("pngScale")) + .setDesc(this.plugin.t("pngScaleDesc")) + .addSlider((slider) => { + slider + .setLimits(1, 4, 1) + .setDynamicTooltip() + .setValue(this.plugin.settings.exportScale) + .onChange(async (value) => { + this.plugin.settings.exportScale = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName(this.plugin.t("transparentPng")) + .setDesc(this.plugin.t("transparentPngDesc")) + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.exportTransparentBackground) + .onChange(async (value) => { + this.plugin.settings.exportTransparentBackground = value; + await this.plugin.saveSettings(); + }); + }); + } +} + +class EmojiSuggestModal extends SuggestModal { + private onChoose: (emoji: string) => void; + + constructor(app: App, placeholder: string, onChoose: (emoji: string) => void) { + super(app); + this.onChoose = onChoose; + this.setPlaceholder(placeholder); + } + + getSuggestions(query: string): string[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return EMOJI_OPTIONS; + return EMOJI_OPTIONS.filter((emoji) => emoji.includes(normalized)); + } + + renderSuggestion(emoji: string, el: HTMLElement): void { + el.createSpan({ cls: "onemind-emoji-suggestion", text: emoji }); + } + + onChooseSuggestion(emoji: string): void { + this.onChoose(emoji); + } +} + +class TagInputModal extends Modal { + private placeholder: string; + private onSubmit: (tag: string) => void; + + constructor(app: App, placeholder: string, onSubmit: (tag: string) => void) { + super(app); + this.placeholder = placeholder; + this.onSubmit = onSubmit; + } + + onOpen(): void { + this.contentEl.empty(); + const input = this.contentEl.createEl("input", { + cls: "onemind-tag-input", + attr: { type: "text", placeholder: this.placeholder } + }); + input.focus(); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.onSubmit(input.value); + this.close(); + } else if (event.key === "Escape") { + event.preventDefault(); + this.close(); + } + }); + } + + onClose(): void { + this.contentEl.empty(); + } +} + +class TagSuggestModal extends SuggestModal { + private tags: string[]; + private onChoose: (tag: string) => void; + + constructor(app: App, tags: string[], placeholder: string, onChoose: (tag: string) => void) { + super(app); + this.tags = tags; + this.onChoose = onChoose; + this.setPlaceholder(placeholder); + } + + getSuggestions(query: string): string[] { + const normalized = normalizeTag(query).toLowerCase(); + const matches = normalized ? this.tags.filter((tag) => tag.toLowerCase().includes(normalized)) : this.tags; + if (normalized && !matches.includes(normalized)) return [normalized, ...matches]; + return matches; + } + + renderSuggestion(tag: string, el: HTMLElement): void { + el.createSpan({ cls: "onemind-tag-suggestion", text: `#${tag}` }); + } + + onChooseSuggestion(tag: string): void { + this.onChoose(tag); + } +} + +class ConflictModal extends Modal { + private labels: { title: string; body: string; reload: string; keep: string }; + private onReload: () => Promise; + private onKeep: () => Promise; + private onCloseCallback: () => void; + + constructor(app: App, labels: { title: string; body: string; reload: string; keep: string }, onReload: () => Promise, onKeep: () => Promise, onCloseCallback: () => void) { + super(app); + this.labels = labels; + this.onReload = onReload; + this.onKeep = onKeep; + this.onCloseCallback = onCloseCallback; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.createEl("h2", { text: this.labels.title }); + contentEl.createEl("p", { + text: this.labels.body + }); + + const actions = contentEl.createDiv({ cls: "onemind-modal-actions" }); + const reloadButton = actions.createEl("button", { text: this.labels.reload }); + reloadButton.onClickEvent(async () => { + this.close(); + await this.onReload(); + }); + + const keepButton = actions.createEl("button", { cls: "mod-cta", text: this.labels.keep }); + keepButton.onClickEvent(async () => { + this.close(); + await this.onKeep(); + }); + } + + onClose(): void { + this.contentEl.empty(); + this.onCloseCallback(); + } +} + +function parseMindDocument(source: string, fallbackTitle: string): MindDocument { + const lines = source.split(/\r?\n/); + let start = 0; + + if (lines[0] === "---") { + const end = lines.findIndex((line, index) => index > 0 && line === "---"); + if (end > 0) start = end + 1; + } + + while (start < lines.length && lines[start].trim() === "") start += 1; + + const firstMapLine = lines.findIndex((line, index) => { + if (index < start) return false; + return /^(#{1,6})\s+(.+?)\s*$/.test(line) || /^(\s*)[-*+]\s+(.+?)\s*$/.test(line); + }); + const mapStart = firstMapLine === -1 ? lines.length : firstMapLine; + const prefix = mapStart > 0 ? `${lines.slice(0, mapStart).join("\n").replace(/\s*$/, "")}\n\n` : ""; + const mapSource = lines.slice(mapStart).join("\n"); + return { root: parseMarkdown(mapSource, fallbackTitle), prefix }; +} + +function parseMarkdown(source: string, fallbackTitle: string): MindNode { + const lines = source.split(/\r?\n/); + let root: MindNode | null = null; + let currentHeadingDepth = 1; + const stack: Array<{ depth: number; node: MindNode }> = []; + + const ensureRoot = (): MindNode => { + if (!root) { + root = createNode(fallbackTitle || "Center topic", "heading"); + stack.push({ depth: 1, node: root }); + } + return root; + }; + + for (const line of lines) { + const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line); + if (heading) { + const depth = heading[1].length; + const node = createNode(heading[2], "heading"); + if (!root && depth === 1) { + root = node; + stack.length = 0; + stack.push({ depth: 1, node }); + } else { + ensureRoot(); + while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop(); + const parent = stack[stack.length - 1]?.node ?? root; + parent.children.push(node); + stack.push({ depth, node }); + } + currentHeadingDepth = depth; + continue; + } + + const list = /^(\s*)[-*+]\s+(.+?)\s*$/.exec(line); + if (list) { + ensureRoot(); + const indentDepth = Math.floor(list[1].replace(/\t/g, " ").length / 2); + const depth = currentHeadingDepth + indentDepth + 1; + const node = createNode(list[2], "list"); + while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop(); + const parent = stack[stack.length - 1]?.node ?? root; + parent.children.push(node); + stack.push({ depth, node }); + } + } + + return root ?? createNode(fallbackTitle || "Center topic", "heading"); +} + +function serializeMarkdown(root: MindNode): string { + const lines: string[] = []; + const visit = (node: MindNode, depth: number, insideList: boolean): void => { + const asHeading = node.kind === "heading" && !insideList && depth <= 6; + if (asHeading) { + lines.push(`${"#".repeat(depth)} ${node.text}`); + } else { + lines.push(`${" ".repeat(Math.max(0, depth - 2))}- ${node.text}`); + } + for (const child of node.children) visit(child, depth + 1, insideList || !asHeading); + }; + visit(root, 1, false); + return `${lines.join("\n")}\n`; +} + +function renderSvgDocument(items: PositionedNode[], title: string, showAssociationLinks = true): string { + const bounds = getBounds(items); + const pad = 80; + const width = Math.ceil(bounds.width + pad * 2); + const height = Math.ceil(bounds.height + pad * 2); + const minX = bounds.minX - pad; + const minY = bounds.minY - pad; + const links = items + .filter((item) => item.parent) + .map((item) => { + const parent = items.find((candidate) => candidate.node === item.parent); + if (!parent) return ""; + const rightward = item.x >= parent.x; + const x1 = rightward ? parent.x + parent.width : parent.x; + const y1 = parent.y + NODE_HEIGHT / 2; + const x2 = rightward ? item.x : item.x + item.width; + const y2 = item.y + NODE_HEIGHT / 2; + const handle = Math.max(60, Math.abs(x2 - x1) * 0.45) * (rightward ? 1 : -1); + return ``; + }) + .join("\n"); + + const associationLinks = showAssociationLinks ? renderAssociationSvgLinks(items) : ""; + + const nodes = items.map((item) => { + const isRoot = item.depth === 0; + const nodeWidth = item.width; + const fontSize = isRoot ? 18 : 14; + const fontWeight = isRoot ? 700 : 400; + const parsedNode = parseNodeText(item.node.text); + const exportLabel = [ + parsedNode.emoji ? `${parsedNode.emoji} ${stripMarkdownFormatting(parsedNode.displayLabel)}` : stripMarkdownFormatting(parsedNode.displayLabel), + parsedNode.tags.map((tag) => `#${tag}`).join(" ") + ].filter(Boolean).join(" "); + const textLines = wrapSvgText(exportLabel, isRoot ? 20 : 22); + const text = textLines.map((line, index) => { + const dy = index === 0 ? 0 : fontSize + 3; + return `${escapeXml(line)}`; + }).join(""); + const nodeHeight = Math.max(NODE_HEIGHT, 24 + textLines.length * (fontSize + 3)); + return [ + ``, + `${text}` + ].join("\n"); + }).join("\n"); + + return [ + ``, + ``, + ``, + links, + associationLinks, + nodes, + `` + ].join("\n"); +} + +function renderAssociationSvgLinks(items: PositionedNode[]): string { + const byAnchor = new Map(); + for (const item of items) byAnchor.set(slugifyNodeText(item.node.text), item); + + const drawn = new Set(); + return items.flatMap((source) => { + return extractLocalWikilinkAnchors(source.node.text, null).map((anchor) => { + const target = byAnchor.get(anchor); + if (!target || target.node.id === source.node.id) return ""; + const key = [source.node.id, target.node.id].sort().join(":"); + if (drawn.has(key)) return ""; + drawn.add(key); + const x1 = source.x + source.width / 2; + const y1 = source.y + NODE_HEIGHT / 2; + const x2 = target.x + target.width / 2; + const y2 = target.y + NODE_HEIGHT / 2; + const midX = (x1 + x2) / 2; + const lift = Math.max(80, Math.abs(y2 - y1) * 0.35); + const midY = Math.min(y1, y2) - lift; + return ``; + }); + }).filter(Boolean).join("\n"); +} + +async function svgToPng(svg: string, scale: number, transparent: boolean): Promise { + const width = Number(/width="(\d+)"/.exec(svg)?.[1] ?? 1200); + const height = Number(/height="(\d+)"/.exec(svg)?.[1] ?? 800); + const multiplier = clamp(Math.round(scale), 1, 4); + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + + try { + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("Could not render SVG export.")); + img.src = url; + }); + const canvas = document.createElement("canvas"); + canvas.width = width * multiplier; + canvas.height = height * multiplier; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas is not available."); + if (!transparent) { + ctx.fillStyle = getComputedStyle(document.body).getPropertyValue("--background-primary").trim() || "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + ctx.scale(multiplier, multiplier); + ctx.drawImage(image, 0, 0, width, height); + const pngBlob = await new Promise((resolve, reject) => { + canvas.toBlob((result) => result ? resolve(result) : reject(new Error("Could not encode PNG export.")), "image/png"); + }); + return await pngBlob.arrayBuffer(); + } finally { + URL.revokeObjectURL(url); + } +} + +function layoutTree(root: MindNode, colors: string[], direction: LayoutDirection): PositionedNode[] { + const result: PositionedNode[] = []; + + const visibleChildren = (node: MindNode): MindNode[] => node.collapsed ? [] : node.children; + const measure = (node: MindNode): number => { + const children = visibleChildren(node); + if (children.length === 0) return NODE_HEIGHT; + return Math.max(NODE_HEIGHT, children.reduce((sum, child) => sum + measure(child), 0) + ROW_GAP * (children.length - 1)); + }; + + const place = (node: MindNode, parent: MindNode | null, depth: number, x: number, top: number, color: string, side: -1 | 1): void => { + const subtreeHeight = measure(node); + const y = top + subtreeHeight / 2 - NODE_HEIGHT / 2; + const width = depth === 0 ? 200 : NODE_WIDTH; + result.push({ node, parent, depth, x, y, width, height: NODE_HEIGHT, color, side }); + + let childTop = top; + const children = visibleChildren(node); + children.forEach((child, index) => { + const childColor = depth === 0 ? colors[index % colors.length] : color; + place(child, node, depth + 1, x + side * LEVEL_GAP, childTop, childColor, side); + childTop += measure(child) + ROW_GAP; + }); + }; + + if (direction === "balanced" && !root.collapsed && root.children.length > 1) { + const children = visibleChildren(root); + const left = children.filter((_, index) => index % 2 === 1); + const right = children.filter((_, index) => index % 2 === 0); + const heightOf = (nodes: MindNode[]): number => nodes.length === 0 ? NODE_HEIGHT : nodes.reduce((sum, child) => sum + measure(child), 0) + ROW_GAP * (nodes.length - 1); + const leftHeight = heightOf(left); + const rightHeight = heightOf(right); + const totalHeight = Math.max(leftHeight, rightHeight, NODE_HEIGHT); + result.push({ node: root, parent: null, depth: 0, x: 0, y: totalHeight / 2 - NODE_HEIGHT / 2, width: 200, height: NODE_HEIGHT, color: colors[0], side: 1 }); + + let leftTop = totalHeight / 2 - leftHeight / 2; + left.forEach((child, index) => { + const originalIndex = children.findIndex((candidate) => candidate === child); + place(child, root, 1, -LEVEL_GAP, leftTop, colors[originalIndex % colors.length], -1); + leftTop += measure(child) + ROW_GAP; + }); + + let rightTop = totalHeight / 2 - rightHeight / 2; + right.forEach((child) => { + const originalIndex = children.findIndex((candidate) => candidate === child); + place(child, root, 1, LEVEL_GAP, rightTop, colors[originalIndex % colors.length], 1); + rightTop += measure(child) + ROW_GAP; + }); + } else { + place(root, null, 0, 0, 0, colors[0], 1); + } + return result; +} + +function renderInlineMarkdown(container: HTMLElement, text: string, app: App): void { + const pattern = /(\[\[[^\]]+\]\]|`[^`]+`|\*\*[^*]+\*\*)/g; + let last = 0; + for (const match of text.matchAll(pattern)) { + if (match.index === undefined) continue; + if (match.index > last) container.appendText(text.slice(last, match.index)); + const token = match[0]; + if (token.startsWith("[[")) { + const target = token.slice(2, -2); + const link = container.createEl("span", { cls: "onemind-wikilink", text: target }); + link.onClickEvent((event) => { + event.stopPropagation(); + void app.workspace.openLinkText(target, "", false); + }); + } else if (token.startsWith("`")) { + container.createEl("code", { text: token.slice(1, -1) }); + } else { + container.createEl("strong", { text: token.slice(2, -2) }); + } + last = match.index + token.length; + } + if (last < text.length) container.appendText(text.slice(last)); +} + +function parseEmojiText(text: string): ParsedEmojiText { + const trimmed = text.trimStart(); + const match = /^((?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?)(?:\u200D(?:\p{Extended_Pictographic})(?:\uFE0F|\uFE0E)?)*)\s+(.+)$/u.exec(trimmed); + if (!match) return { emoji: null, label: text }; + return { emoji: match[1], label: match[2].trimStart() || "Untitled" }; +} + +function parseNodeText(text: string): ParsedNodeText { + const parsed = parseEmojiText(text); + const tags: string[] = []; + const displayLabel = parsed.label + .replace(/(^|\s)#([\p{Letter}\p{Number}_/-]+)/gu, (_match, prefix: string, tag: string) => { + tags.push(tag); + return prefix; + }) + .replace(/\s{2,}/g, " ") + .trim() || parsed.label; + return { ...parsed, tags, displayLabel }; +} + +function normalizeTag(tag: string): string { + return tag.trim().replace(/^#/, "").replace(/[^\p{Letter}\p{Number}_/-]/gu, ""); +} + +function addTagToText(text: string, tag: string): string { + const parsed = parseNodeText(text); + if (parsed.tags.includes(tag)) return text; + return `${text.trim()} #${tag}`; +} + +function removeNodeTags(text: string): string { + const parsed = parseNodeText(text); + const prefix = parsed.emoji ? `${parsed.emoji} ` : ""; + return `${prefix}${parsed.displayLabel}`.trim() || "Untitled"; +} + +function stripInlineMarkdown(text: string): string { + const parsed = parseNodeText(text); + return stripMarkdownFormatting(`${parsed.emoji ? `${parsed.emoji} ` : ""}${parsed.displayLabel}${parsed.tags.length > 0 ? ` ${parsed.tags.map((tag) => `#${tag}`).join(" ")}` : ""}`); +} + +function stripMarkdownFormatting(text: string): string { + return text + .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, target: string, alias: string | undefined) => alias ?? target) + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"); +} + +function extractLocalWikilinkAnchors(text: string, file: TFile | null): string[] { + const anchors: string[] = []; + const basename = file?.basename.toLowerCase(); + const pathBase = file?.path.replace(/\.md$/i, "").toLowerCase(); + const pattern = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g; + for (const match of text.matchAll(pattern)) { + const raw = match[1].trim(); + if (!raw) continue; + const [rawFile, rawHeading] = raw.includes("#") ? raw.split("#", 2) : ["", raw]; + const targetFile = rawFile.trim().replace(/\.md$/i, "").toLowerCase(); + if (targetFile && targetFile !== basename && targetFile !== pathBase) continue; + anchors.push(slugifyNodeText(rawHeading)); + } + return anchors.filter(Boolean); +} + +function wrapSvgText(text: string, maxChars: number): string[] { + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return ["Untitled"]; + const lines: string[] = []; + let current = ""; + for (const word of words) { + if (word.length > maxChars) { + if (current) lines.push(current); + for (let index = 0; index < word.length; index += maxChars) { + lines.push(word.slice(index, index + maxChars)); + } + current = ""; + continue; + } + const next = current ? `${current} ${word}` : word; + if (next.length > maxChars && current) { + lines.push(current); + current = word; + } else { + current = next; + } + } + if (current) lines.push(current); + return lines.slice(0, 4); +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function createNode(text: string, kind: NodeKind): MindNode { + return { + id: `node-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + text: text.trim() || "Untitled", + kind, + children: [], + collapsed: false + }; +} + +function cloneNode(node: MindNode): MindNode { + return { + id: node.id, + text: node.text, + kind: node.kind, + collapsed: node.collapsed, + children: node.children.map((child) => cloneNode(child)) + }; +} + +function cloneNodeWithNewIds(node: MindNode): MindNode { + return { + id: createNode(node.text, node.kind).id, + text: node.text, + kind: node.kind, + collapsed: node.collapsed, + children: node.children.map((child) => cloneNodeWithNewIds(child)) + }; +} + +function filterTreeByTag(root: MindNode, tag: string): MindNode | null { + const children = root.children + .map((child) => filterTreeByTag(child, tag)) + .filter((child): child is MindNode => Boolean(child)); + if (parseNodeText(root.text).tags.includes(tag) || children.length > 0) { + return { ...root, collapsed: false, children }; + } + return null; +} + +function subtreeHasTag(root: MindNode, tag: string): boolean { + return parseNodeText(root.text).tags.includes(tag) || root.children.some((child) => subtreeHasTag(child, tag)); +} + +function normalizePastedNodeKinds(node: MindNode, preferredKind: NodeKind, depth: number): void { + node.id = createNode(node.text, node.kind).id; + node.kind = preferredKind === "heading" && depth <= 5 ? "heading" : "list"; + for (const child of node.children) normalizePastedNodeKinds(child, node.kind, depth + 1); +} + +function applyDefaultExpandDepth(root: MindNode, maxDepth: number, depth = 0): void { + root.collapsed = depth >= maxDepth; + for (const child of root.children) applyDefaultExpandDepth(child, maxDepth, depth + 1); +} + +function setCollapsedRecursive(root: MindNode, collapsed: boolean): void { + root.collapsed = collapsed; + for (const child of root.children) setCollapsedRecursive(child, collapsed); +} + +function nextKind(parent: MindNode, parentDepth: number): NodeKind { + if (parent.kind === "heading" && parentDepth < 5) return "heading"; + return "list"; +} + +function findNode(root: MindNode, id: string): MindNode | null { + if (root.id === id) return root; + for (const child of root.children) { + const found = findNode(child, id); + if (found) return found; + } + return null; +} + +function findNodeByAnchor(root: MindNode, anchor: string): MindNode | null { + const normalized = normalizeAnchor(anchor); + if (slugifyNodeText(root.text) === normalized) return root; + for (const child of root.children) { + const found = findNodeByAnchor(child, normalized); + if (found) return found; + } + return null; +} + +function findParent(root: MindNode, id: string): MindNode | null { + for (const child of root.children) { + if (child.id === id) return root; + const found = findParent(child, id); + if (found) return found; + } + return null; +} + +function deleteNodes(root: MindNode, ids: Set): void { + root.children = root.children.filter((child) => !ids.has(child.id)); + for (const child of root.children) deleteNodes(child, ids); +} + +function flattenNodes(root: MindNode): MindNode[] { + const nodes: MindNode[] = []; + const visit = (node: MindNode): void => { + nodes.push(node); + for (const child of node.children) visit(child); + }; + visit(root); + return nodes; +} + +function findPath(root: MindNode, id: string): MindNode[] { + if (root.id === id) return [root]; + for (const child of root.children) { + const path = findPath(child, id); + if (path.length > 0) return [root, ...path]; + } + return []; +} + +function getDescendantIds(node: MindNode | null): string[] { + if (!node) return []; + const ids: string[] = []; + const visit = (current: MindNode): void => { + for (const child of current.children) { + ids.push(child.id); + visit(child); + } + }; + visit(node); + return ids; +} + +function moveNode(root: MindNode, nodeId: string, target: DropTarget): boolean { + if (nodeId === root.id || nodeId === target.parentId) return false; + const oldParent = findParent(root, nodeId); + const newParent = findNode(root, target.parentId); + if (!oldParent || !newParent) return false; + + const movingIndex = oldParent.children.findIndex((child) => child.id === nodeId); + if (movingIndex === -1) return false; + const [moving] = oldParent.children.splice(movingIndex, 1); + if (!moving) return false; + + let insertIndex = target.mode === "child" ? newParent.children.length : target.index; + if (oldParent.id === newParent.id && movingIndex < insertIndex) insertIndex -= 1; + insertIndex = clamp(insertIndex, 0, newParent.children.length); + moving.kind = nextKind(newParent, getNodeDepth(root, newParent.id)); + newParent.children.splice(insertIndex, 0, moving); + newParent.collapsed = false; + return true; +} + +function moveNodeByKeyboard(root: MindNode, nodeId: string, direction: "up" | "down" | "indent" | "outdent"): boolean { + const parent = findParent(root, nodeId); + const node = findNode(root, nodeId); + if (!parent || !node) return false; + const index = parent.children.findIndex((child) => child.id === nodeId); + if (index === -1) return false; + + if (direction === "up") { + if (index === 0) return false; + [parent.children[index - 1], parent.children[index]] = [parent.children[index], parent.children[index - 1]]; + return true; + } + + if (direction === "down") { + if (index >= parent.children.length - 1) return false; + [parent.children[index], parent.children[index + 1]] = [parent.children[index + 1], parent.children[index]]; + return true; + } + + if (direction === "indent") { + if (index === 0) return false; + const newParent = parent.children[index - 1]; + parent.children.splice(index, 1); + node.kind = nextKind(newParent, getNodeDepth(root, newParent.id)); + newParent.children.push(node); + newParent.collapsed = false; + return true; + } + + const grandParent = findParent(root, parent.id); + if (!grandParent) return false; + parent.children.splice(index, 1); + const parentIndex = grandParent.children.findIndex((child) => child.id === parent.id); + node.kind = nextKind(grandParent, getNodeDepth(root, grandParent.id)); + grandParent.children.splice(parentIndex + 1, 0, node); + return true; +} + +function normalizeAnchor(anchor: string): string { + return decodeURIComponent(anchor) + .replace(/^#+/, "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-"); +} + +function slugifyNodeText(text: string): string { + return stripInlineMarkdown(text) + .trim() + .toLowerCase() + .replace(/[^\p{Letter}\p{Number}\p{Emoji_Presentation}\s-]/gu, "") + .replace(/\s+/g, "-"); +} + +function getNodeDepth(root: MindNode, id: string, depth = 0): number { + if (root.id === id) return depth; + for (const child of root.children) { + const found = getNodeDepth(child, id, depth + 1); + if (found !== -1) return found; + } + return -1; +} + +function getBounds(items: PositionedNode[]): { minX: number; minY: number; width: number; height: number } { + const minX = Math.min(...items.map((item) => item.x)); + const minY = Math.min(...items.map((item) => item.y)); + const maxX = Math.max(...items.map((item) => item.x + item.width)); + const maxY = Math.max(...items.map((item) => item.y + item.height)); + return { minX, minY, width: maxX - minX, height: maxY - minY }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function createSvg(tag: K): SVGElementTagNameMap[K] { + return document.createElementNS("http://www.w3.org/2000/svg", tag); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6b2564d --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "onemind", + "name": "OneMind", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Edit Markdown as a clean, keyboard-friendly mind map.", + "author": "", + "authorUrl": "", + "isDesktopOnly": false +} diff --git a/ob思维导图插件.md b/ob思维导图插件.md new file mode 100644 index 0000000..34f60fb --- /dev/null +++ b/ob思维导图插件.md @@ -0,0 +1,302 @@ +--- +title: Obsidian 思维导图插件 - 产品方案 +created: 2026-05-16 +status: draft +tags: + - 产品方案 + - obsidian插件 + - 思维导图 +--- + +# Obsidian 思维导图插件 - 产品方案 + +## 1. 产品概述 + +### 1.1 插件名称 + +**OneMind** + +> "One" from ONE PIECE + Einstein's "Mind"。 +> 像路飞寻找 One Piece 一样,每个思维导图都是一次寻找"思维宝藏"的旅程。 +> 极简,好记,搜索友好。 + +### 1.2 一句话定位 + +一款以编辑体验为核心的 Obsidian 思维导图插件,让用户直接在极简风格的思维导图上思考和组织想法,所有内容实时同步为纯 Markdown 格式。 + +### 1.2 解决的核心问题 + +| 现有痛点 | 我们的解法 | +|---------|-----------| +| 现有插件只是 markdown 的只读可视化 | 编辑器优先,思维导图本身就是工作区 | +| 视觉粗糙、风格老旧 | Notion/Linear 式极简美学 | +| 编辑能力弱,几乎不能在导图上操作 | 完整的键盘+鼠标编辑体验 | +| 独立文件格式,破坏纯文本理念 | 纯 Markdown 双向同步 | + +### 1.3 目标用户 + +- Obsidian 重度用户,习惯用标题和列表组织内容 +- 不满足于线性文本,需要可视化思考的人群 +- 对工具有审美要求,不愿接受丑陋界面的用户 + +### 1.4 发布目标 + +社区插件,上架 Obsidian Plugin Marketplace。 + +--- + +## 2. 核心功能(MVP) + +### 2.1 视图切换 + +- 命令面板命令或图标按钮:在 Markdown 编辑视图和思维导图视图之间切换 +- 思维导图视图是一个独立的全屏编辑视图(非侧边栏预览) +- 切换时保留光标/选中节点的上下文 + +### 2.2 Markdown ↔ 思维导图映射规则 + +``` +# 中心主题 → 根节点 +## 分支一 → 一级子节点 +### 子节点 1.1 → 二级子节点 +- 列表项 → 叶子节点 +- **加粗** → 节点加粗样式 +- [[链接]] → 可点击跳转的链接节点 +- `代码` → 节点内行内代码样式 +``` + +- 标题层级 (H1-H6) → 节点层级 +- 无序列表 `- item` → 当前标题下的子节点 +- 支持混合使用标题和列表 + +### 2.3 节点编辑 + +| 操作 | 快捷键 | 说明 | +|------|--------|------| +| 添加子节点 | `Tab` | 在选中节点下创建子节点,自动进入编辑模式 | +| 添加同级节点 | `Enter` | 在选中节点后创建同级节点,自动进入编辑模式 | +| 编辑节点文本 | `F2` 或双击 | 进入文本编辑模式 | +| 删除节点 | `Delete` / `Backspace` | 删除选中节点及其所有子节点 | +| 展开/折叠 | `Space` | 折叠/展开子节点 | +| 撤销/重做 | `Ctrl+Z` / `Ctrl+Shift+Z` | 撤销/重做操作 | + +### 2.4 节点拖拽 + +- 拖拽节点到另一个节点上 → 成为其子节点 +- 拖拽节点到两个节点之间 → 插入为同级节点 +- 拖拽时显示视觉指示(高亮放置位置) +- 拖拽完成后实时更新 Markdown + +### 2.5 画布操作 + +| 操作 | 方式 | 说明 | +|------|------|------| +| 平移 | 鼠标拖拽空白区域 / 方向键 | 移动画布视口 | +| 缩放 | 滚轮 / `Ctrl +/-` | 缩放画布 | +| 适应窗口 | `Ctrl+Shift+F` | 自动缩放以显示全部内容 | +| 聚焦选中 | `Ctrl+F` | 平移画布使选中节点居中 | + +### 2.6 实时 Markdown 同步 + +- 在思维导图视图中的任何编辑,实时更新对应的 Markdown 源文件 +- Markdown 文件在外部被修改时(如同步、其他编辑器),思维导图视图自动重新解析 +- 同步策略:以 Markdown 为 source of truth,导图编辑通过修改 Markdown 文本来实现 +- 支持 Obsidian 原生撤销栈(Undo/Redo 走 Obsidian 的 editor transaction) + +--- + +## 3. 视觉设计 + +### 3.1 设计原则 + +- **克制**:细线条、少颜色、多留白 +- **清晰**:层次分明,一眼看出结构关系 +- **流畅**:动画自然,过渡丝滑 + +### 3.2 设计规范 + +``` +连线: + - 线宽:1.5px + - 线型:贝塞尔曲线(水平方向展开) + - 颜色:跟随节点层级自动分配柔和色系 + +节点: + - 圆角:8px + - 内边距:8px 16px + - 根节点:较大,加粗字体 + - 分支节点:适中 + - 叶子节点:较小 + +字体: + - 使用 Obsidian 主题字体(跟随用户设置) + - 根节点:18px 加粗 + - 子节点:14px 常规 + +配色: + - 跟随 Obsidian 主题(亮色/暗色自动适配) + - 分支颜色自动分配,色板预设(可配置) + - 默认色板:蓝、绿、橙、紫、粉 5色柔和色系 + +间距: + - 同级节点垂直间距:16px + - 父子节点水平间距:60px + - 根节点居中显示 +``` + +### 3.3 布局方向 + +- **默认**:水平树形布局(根节点在左,分支向右展开) +- **可选**:水平双向布局(根节点居中,分支向两侧展开) +- 自动布局算法:每次编辑后重新计算节点位置,带有平滑过渡动画 + +--- + +## 4. 技术架构 + +### 4.1 渲染层 + +``` +┌─────────────────────────────────┐ +│ Obsidian View │ +│ ┌───────────────────────────┐ │ +│ │ Canvas Container │ │ +│ │ │ │ +│ │ ┌─────┐ ┌─────┐ │ │ +│ │ │ DOM │───│ DOM │ │ │ +│ │ │ 节点│ SVG│ 节点│ │ │ +│ │ └─────┘连线└─────┘ │ │ +│ │ │ │ +│ └───────────────────────────┘ │ +└─────────────────────────────────┘ +``` + +- **SVG 层**:负责绘制节点之间的贝塞尔曲线连线 +- **DOM 层**:负责渲染节点卡片(文本、样式、交互),叠加在 SVG 之上 +- **容器**:可缩放、可平移的画布容器,使用 CSS `transform` 实现 + +### 4.2 数据流 + +``` +Markdown 源文件 + │ + ▼ + Markdown Parser ──→ Tree Data Model ──→ Layout Engine ──→ Renderer (SVG + DOM) + ▲ │ + │ ▼ + Markdown Writer ◄── Tree Data Model ◄── User Interaction (编辑/拖拽) +``` + +- **Markdown Parser**:解析标题层级和列表结构,构建树形数据模型 +- **Tree Data Model**:内存中的树形数据结构,每个节点包含 id、text、children、style 等属性 +- **Layout Engine**:根据树结构计算每个节点的 x/y 坐标,实现自动布局算法 +- **Renderer**:根据布局结果渲染 SVG 连线和 DOM 节点 +- **Markdown Writer**:将树形数据模型序列化回 Markdown 文本 + +### 4.3 自动布局算法 + +- 采用紧凑树形布局(Compact Tree Layout) +- 递归计算每个子树的高度,均匀分配垂直空间 +- 编辑操作后增量更新布局,非全量重排(性能考虑) +- 位置变化时使用 CSS transition 实现平滑动画过渡 + +### 4.4 Markdown 同步策略 + +- **编辑导图时**:修改 Tree Data Model → 序列化为 Markdown → 通过 Obsidian Editor API 写入文件 +- **文件被外部修改时**:监听 `vault.on('modify')` 事件 → 重新解析 Markdown → 差量更新 Tree Data Model → 增量重渲染 +- **冲突处理**:以 Markdown 文件为 source of truth;如果用户正在编辑节点时文件被外部修改,提示用户选择保留哪个版本 + +### 4.5 技术栈 + +| 模块 | 技术 | 说明 | +|------|------|------| +| 插件框架 | Obsidian API + TypeScript | 标准 Obsidian 插件开发 | +| 渲染 | SVG + DOM | 不依赖额外渲染库 | +| Markdown 解析 | 内置轻量解析器 | 只处理标题和列表层级 | +| 构建工具 | esbuild / Vite | Obsidian 插件标准构建 | + +--- + +## 5. 插件设置 + +### 5.1 设置项 + +``` +外观设置: + - 布局方向:水平向右 / 水平双向 + - 分支颜色方案:预设色板选择 / 自定义色板 + - 跟随 Obsidian 主题:开/关 + +行为设置: + - 自动保存延迟:输入后多久同步到 Markdown(默认 300ms) + - 展开折叠动画:开/关 + - 展开深度:首次打开时默认展开到第几层(默认全部展开) + +快捷键: + - 所有操作均支持自定义快捷键(走 Obsidian 原生快捷键系统) +``` + +--- + +## 6. 发布计划 + +### Phase 1:核心 MVP + +- [ ] 插件骨架:注册 View,实现视图切换 +- [ ] Markdown 解析器:标题+列表 → 树形结构 +- [ ] 渲染引擎:SVG 连线 + DOM 节点 +- [ ] 自动布局算法 +- [ ] 基础编辑:添加/删除/编辑节点文本 +- [ ] Markdown 同步(单向:导图 → Markdown) +- [ ] 缩放和平移 +- [ ] 暗色/亮色主题适配 + +### Phase 2:交互增强 + +- [ ] 节点拖拽排序 +- [ ] 展开/折叠子节点 +- [ ] Markdown 反向同步(文件修改 → 导图更新) +- [ ] 节点搜索和导航 +- [ ] 快捷键完善 +- [ ] 平滑动画过渡 + +### Phase 3:体验打磨 + +- [ ] 多种布局方向支持 +- [ ] 节点样式自定义(颜色、标记) +- [ ] 导出为 PNG/SVG +- [ ] 设置面板完善 +- [ ] 中英文国际化 +- [ ] 移动端适配 + +### Phase 4:高级功能 + +- [ ] 节点间关联线(跨分支连接) +- [ ] 节点图标/标记系统 +- [ ] 多选和批量操作 +- [ ] 大纲缩略图导航 +- [ ] 协同编辑支持(与 Obsidian Sync 兼容) + +--- + +## 7. 竞品对比 + +| 能力 | 我们的插件 | Mind Map (Markmap) | Enhancing Mindmap | Canvas Mindmap | XMind (独立) | +|------|-----------|-------------------|------------------|----------------|-------------| +| 编辑体验 | ⭐⭐⭐ 完整编辑 | ❌ 只读 | ⭐ 基础编辑 | ⭐ 基础编辑 | ⭐⭐⭐ 完整 | +| 视觉美学 | ⭐⭐⭐ 极简 | ⭐ 一般 | ⭐⭐ 老旧 | ⭐ 一般 | ⭐⭐⭐ 精美 | +| Markdown 同步 | ⭐⭐⭐ 双向实时 | ⭐ 单向只读 | ⭐⭐ 基础 | ❌ 无 | ❌ 纯私有格式 | +| Obsidian 原生 | ⭐⭐⭐ 深度 | ⭐⭐ 中等 | ⭐⭐ 中等 | ⭐⭐⭐ Canvas | ❌ 独立应用 | +| 键盘操作 | ⭐⭐⭐ 完整 | ❌ 无 | ⭐⭐ 基础 | ⭐⭐ 基础 | ⭐⭐⭐ 完整 | + +**核心差异化**:唯一一款同时做到"编辑器级体验 + 极简美学 + 纯 Markdown 同步"的 Obsidian 思维导图插件。 + +--- + +## 8. 开放问题 + +- [x] 插件名称:OneMind +- [x] 支持 `Obsidian URI` 协议跳转到特定节点(如 `obsidian://open?vault=xxx&file=yyy#heading-zzz` 打开后自动定位到对应节点) +- [x] ~~是否支持 Dataview 查询结果作为节点内容~~ 暂不考虑 +- [x] ~~是否支持 `callout` / `代码块` 在节点中的显示~~ 暂不考虑 +- [x] 性能基准:支持 1000 节点的思维导图(布局计算和渲染均需在可接受时间内完成) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bead3de --- /dev/null +++ b/package-lock.json @@ -0,0 +1,657 @@ +{ + "name": "onemind", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "onemind", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.30", + "builtin-modules": "^3.3.0", + "esbuild": "^0.25.12", + "obsidian": "^1.5.12", + "typescript": "^5.4.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fc9fb5c --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "onemind", + "version": "0.1.0", + "description": "An editor-first mind map plugin for Obsidian that syncs with plain Markdown.", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + }, + "keywords": [ + "obsidian", + "mind-map", + "markdown" + ], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.30", + "builtin-modules": "^3.3.0", + "esbuild": "^0.25.12", + "obsidian": "^1.5.12", + "typescript": "^5.4.5" + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..ec6b7c9 --- /dev/null +++ b/styles.css @@ -0,0 +1,506 @@ +.onemind-view { + --onemind-node-bg: var(--background-primary); + --onemind-node-border: var(--background-modifier-border); + --onemind-node-shadow: rgba(15, 23, 42, 0.08); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--background-primary); +} + +.theme-dark .onemind-view { + --onemind-node-shadow: rgba(0, 0, 0, 0.28); +} + +.onemind-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-height: 40px; + padding: 6px 10px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-primary); + flex: 0 0 auto; +} + +.onemind-search { + position: relative; + margin-left: 6px; + width: min(260px, 34vw); + min-width: 140px; +} + +@media (max-width: 560px) { + .onemind-toolbar { + align-items: stretch; + } + + .onemind-search { + order: 2; + width: 100%; + min-width: 100%; + margin-left: 0; + } + + .onemind-node { + width: 168px; + padding-left: 12px; + padding-right: 10px; + } + + .onemind-node.is-root { + width: 184px; + } + + .onemind-outline { + left: 10px; + right: 10px; + top: 10px; + bottom: auto; + width: auto; + max-height: 42%; + min-width: 0; + } +} + +.onemind-search-input { + width: 100%; + height: 28px; + padding: 4px 44px 4px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-secondary); + color: var(--text-normal); + font-size: 13px; +} + +.onemind-search-input:focus { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--interactive-accent) 18%, transparent); +} + +.onemind-search-count { + position: absolute; + right: 8px; + top: 50%; + max-width: 36px; + transform: translateY(-50%); + color: var(--text-muted); + font-size: 11px; + line-height: 1; + text-align: right; + pointer-events: none; +} + +.onemind-tool-button { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-muted); +} + +.onemind-tool-button:hover { + border-color: var(--background-modifier-border); + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.onemind-canvas { + position: relative; + flex: 1 1 auto; + overflow: hidden; + cursor: grab; + touch-action: none; + background: + radial-gradient(circle at 1px 1px, var(--background-modifier-border) 1px, transparent 0) 0 0 / 28px 28px, + var(--background-primary); +} + +.onemind-canvas:active { + cursor: grabbing; +} + +.onemind-status { + position: absolute; + left: 12px; + bottom: 12px; + max-width: min(520px, calc(100% - 24px)); + padding: 5px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: color-mix(in srgb, var(--background-primary) 92%, transparent); + color: var(--text-muted); + box-shadow: 0 8px 20px var(--onemind-node-shadow); + font-size: 12px; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + z-index: 25; +} + +.onemind-outline { + position: absolute; + right: 12px; + top: 12px; + bottom: 12px; + width: min(280px, 32vw); + min-width: 210px; + display: flex; + flex-direction: column; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: color-mix(in srgb, var(--background-primary) 94%, transparent); + box-shadow: 0 12px 28px var(--onemind-node-shadow); + z-index: 30; + overflow: hidden; +} + +.onemind-outline.is-hidden { + display: none; +} + +.onemind-outline-header { + height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 6px 4px 12px; + border-bottom: 1px solid var(--background-modifier-border); + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0; +} + +.onemind-outline-list { + overflow: auto; + padding: 6px; +} + +.onemind-outline-row { + min-height: 28px; + display: flex; + align-items: center; + gap: 6px; + border-radius: 6px; + color: var(--text-muted); + font-size: 13px; + line-height: 1.25; + cursor: pointer; +} + +.onemind-outline-row:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.onemind-outline-row.is-selected { + background: color-mix(in srgb, var(--interactive-accent) 14%, transparent); + color: var(--text-normal); +} + +.onemind-outline-emoji { + flex: 0 0 auto; + font-size: 14px; +} + +.onemind-outline-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.onemind-outline-count { + flex: 0 0 auto; + margin-left: auto; + padding: 1px 5px; + border-radius: 999px; + background: var(--background-secondary); + color: var(--text-faint); + font-size: 11px; +} + +.onemind-outline-filter { + display: flex; + align-items: center; + gap: 8px; + margin: 2px 2px 6px; + padding: 6px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--interactive-accent) 12%, transparent); + color: var(--text-normal); + font-size: 12px; +} + +.onemind-outline-filter button { + margin-left: auto; +} + +.onemind-scene { + position: absolute; + inset: 0; + transform-origin: 0 0; +} + +.onemind-links, +.onemind-nodes { + position: absolute; + inset: 0; + overflow: visible; +} + +.onemind-links path { + stroke-width: 1.5px; + stroke-linecap: round; + opacity: 0.78; +} + +.onemind-links path.onemind-association-link { + stroke: var(--text-faint); + stroke-width: 1.2px; + stroke-dasharray: 5 5; + opacity: 0.72; +} + +.onemind-node { + position: absolute; + width: 180px; + min-height: 42px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 8px 12px 8px 14px; + border: 1.5px solid var(--onemind-node-border); + border-radius: 8px; + background: var(--onemind-node-bg); + box-shadow: 0 8px 24px var(--onemind-node-shadow); + color: var(--text-normal); + font-size: 14px; + line-height: 1.35; + cursor: default; + touch-action: none; + transition: left 160ms ease, top 160ms ease, box-shadow 160ms ease, border-color 160ms ease, transform 160ms ease; + user-select: none; +} + +.onemind-view.is-animation-disabled .onemind-node { + transition: none; +} + +.onemind-node:hover { + transform: translateY(-1px); +} + +.onemind-node.is-root { + width: 200px; + min-height: 48px; + font-size: 18px; + font-weight: 700; +} + +.onemind-node.is-selected { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--interactive-accent) 24%, transparent), 0 10px 26px var(--onemind-node-shadow); +} + +.onemind-node.is-primary-selected { + border-width: 2px; +} + +.onemind-node.is-dragging { + z-index: 20; + opacity: 0.88; + cursor: grabbing; + pointer-events: none; + transition: none; + box-shadow: 0 14px 34px var(--onemind-node-shadow); +} + +.onemind-node.is-drop-child { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--interactive-accent) 32%, transparent), 0 10px 26px var(--onemind-node-shadow); +} + +.onemind-node.is-search-match { + background: color-mix(in srgb, var(--interactive-accent) 8%, var(--onemind-node-bg)); +} + +.onemind-node.is-active-search-match { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-accent) 34%, transparent), 0 10px 26px var(--onemind-node-shadow); +} + +.onemind-node-emoji { + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border-radius: 6px; + background: var(--background-secondary); + font-size: 17px; + line-height: 1; +} + +.onemind-node.is-root .onemind-node-emoji { + width: 30px; + height: 30px; + font-size: 20px; +} + +.onemind-node-content { + min-width: 0; + flex: 1 1 auto; + overflow-wrap: anywhere; +} + +.onemind-node-tags { + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 2px; + padding-left: 34px; +} + +.onemind-node:not(.has-emoji) .onemind-node-tags { + padding-left: 0; +} + +.onemind-node-tag { + max-width: 100%; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--interactive-accent) 10%, var(--background-secondary)); + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.onemind-node-tag:hover, +.onemind-node-tag.is-active { + background: color-mix(in srgb, var(--interactive-accent) 22%, var(--background-secondary)); + color: var(--text-normal); +} + +.onemind-node-content code { + padding: 1px 4px; + border-radius: 4px; + background: var(--code-background); + color: var(--code-normal); + font-size: 0.92em; +} + +.onemind-wikilink { + color: var(--link-color); + cursor: pointer; +} + +.onemind-wikilink:hover { + text-decoration: underline; +} + +.onemind-collapse { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border-radius: 5px; + color: var(--text-muted); +} + +.onemind-collapse:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.onemind-editor { + width: 100%; + min-height: 36px; + resize: none; + border: 0; + outline: 0; + background: transparent; + color: var(--text-normal); + font: inherit; + line-height: 1.35; +} + +.onemind-drop-marker { + position: absolute; + height: 2px; + border-radius: 999px; + background: var(--interactive-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--interactive-accent) 22%, transparent); + pointer-events: none; + z-index: 15; +} + +.onemind-drop-marker::before { + content: ""; + position: absolute; + left: -4px; + top: -3px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--interactive-accent); +} + +.onemind-empty { + margin: auto; + color: var(--text-muted); +} + +.onemind-emoji-suggestion { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + font-size: 22px; + line-height: 1; +} + +.onemind-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; +} + +.onemind-tag-input { + width: 100%; + height: 36px; + padding: 6px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-secondary); + color: var(--text-normal); +} + +.onemind-tag-suggestion { + font-size: 13px; + color: var(--text-normal); +} + +.onemind-settings-palette { + margin: 4px 0 10px; + padding-left: 12px; + border-left: 2px solid var(--background-modifier-border); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ce4a7e2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2018", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": [ + "DOM", + "ES2018" + ] + }, + "include": [ + "**/*.ts" + ] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +}