├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── docs │ ├── auto-node-resizing.gif │ ├── border-styles.png │ ├── collapsible-groups.png │ ├── custom-colors.png │ ├── custom-style-attribute-example.png │ ├── diamond-shape.svg │ ├── document-shape.svg │ ├── edge-arrow-styles.png │ ├── edge-metadata-cache.png │ ├── edge-path-styles.png │ ├── edge-pathfinding-methods.png │ ├── example-custom-node-style.css │ ├── flip-edge.gif │ ├── floating-edge-example.png │ ├── focus-mode.png │ ├── image-export-example.svg │ ├── logo-dark.png │ ├── logo-light.png │ ├── logo.svg │ ├── metadata-cache-support.png │ ├── sample-flowchart.png │ ├── sample-portal-usage.png │ ├── sample-presentation-complex.gif │ ├── sample-presentation-complex.png │ ├── sample-presentation-simple.gif │ ├── sample-presentation-simple.png │ ├── sample-search.gif │ ├── stickers.png │ ├── watermark.svg │ └── z-ordering-control.png ├── flowchart-nodes │ ├── database.png │ ├── decision.png │ ├── document.png │ ├── input-output.png │ ├── predefined-process.png │ ├── process.png │ ├── reference.png │ └── terminal.png ├── formats │ └── advanced-json-canvas │ │ ├── README.md │ │ └── spec │ │ ├── 1.0-1.0.d.ts │ │ └── 1.0-1.0.md └── notes │ ├── backlinks-analysis.md │ ├── graph-analysis.md │ ├── metadata-cache-analysis.md │ └── outgoing-links-analysis.md ├── esbuild.config.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── @types │ ├── AdvancedJsonCanvas.d.ts │ ├── BacklinkPlugin.d.ts │ ├── Canvas.d.ts │ ├── CustomWorkspaceEvents.d.ts │ ├── Obsidian.d.ts │ ├── OutgoingLinkPlugin.d.ts │ ├── PropertiesPlugin.d.ts │ ├── SearchPlugin.d.ts │ ├── Settings.d.ts │ └── SuggestManager.d.ts ├── advanced-canvas-embed.ts ├── canvas-extensions │ ├── advanced-styles │ │ ├── edge-pathfinding-methods │ │ │ ├── edge-pathfinding-method.ts │ │ │ ├── pathfinding-a-star.ts │ │ │ ├── pathfinding-direct.ts │ │ │ └── pathfinding-square.ts │ │ ├── edge-styles.ts │ │ ├── node-styles.ts │ │ └── style-config.ts │ ├── auto-file-node-edges-canvas-extension.ts │ ├── auto-resize-node-canvas-extension.ts │ ├── better-default-settings-canvas-extension.ts │ ├── better-readonly-canvas-extension.ts │ ├── canvas-extension.ts │ ├── collapsible-groups-canvas-extension.ts │ ├── color-palette-canvas-extension.ts │ ├── commands-canvas-extension.ts │ ├── dataset-exposers │ │ ├── canvas-metadata-exposer.ts │ │ ├── canvas-wrapper-exposer.ts │ │ ├── edge-exposer.ts │ │ ├── node-exposer.ts │ │ └── node-interaction-exposer.ts │ ├── edge-highlight-canvas-extension.ts │ ├── encapsulate-canvas-extension.ts │ ├── export-canvas-extension.ts │ ├── flip-edge-canvas-extension.ts │ ├── floating-edge-canvas-extension.ts │ ├── focus-mode-canvas-extension.ts │ ├── frontmatter-control-button-canvas-extension.ts │ ├── group-canvas-extension.ts │ ├── metadata-canvas-extension.ts │ ├── node-ratio-canvas-extension.ts │ ├── portals-canvas-extension.ts │ ├── presentation-canvas-extension.ts │ ├── variable-breakpoint-canvas-extension.ts │ └── z-ordering-canvas-extension.ts ├── main.ts ├── managers │ ├── css-styles-config-manager.ts │ └── windows-manager.ts ├── patchers │ ├── backlinks-patcher.ts │ ├── canvas-patcher.ts │ ├── embed-patcher.ts │ ├── link-suggestions-patcher.ts │ ├── metadata-cache-patcher.ts │ ├── outgoing-links-patcher.ts │ ├── patcher.ts │ ├── properties-patcher.ts │ ├── search-command-patcher.ts │ └── search-patcher.ts ├── settings.ts ├── styles.scss ├── styles │ ├── better-default-settings.scss │ ├── better-readonly.scss │ ├── collapsible-groups.scss │ ├── edge-styles.scss │ ├── export.scss │ ├── floating-edge.scss │ ├── focus-mode.scss │ ├── menu.scss │ ├── node-styles.scss │ ├── portals.scss │ ├── presentation.scss │ ├── search-command.scss │ └── settings.scss └── utils │ ├── bbox-helper.ts │ ├── canvas-helper.ts │ ├── debug-helper.ts │ ├── filepath-helper.ts │ ├── hash-helper.ts │ ├── icons-helper.ts │ ├── migration-helper.ts │ ├── modal-helper.ts │ ├── svg-path-helper.ts │ └── text-helper.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: mikadev 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue of Advanced Canvas. 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Share your idea on how to improve Advanced Canvas. 4 | title: "[FR]" 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Add release assets 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | build-and-upload: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - name: Install Dependencies 24 | run: npm install 25 | 26 | - name: Build in Production Mode 27 | run: npm run build 28 | 29 | - name: Verify Files Exist 30 | run: ls -la dist/main.js dist/styles.css dist/manifest.json 31 | 32 | - name: Upload Release Assets (Original Names) 33 | uses: softprops/action-gh-release@v1 34 | with: 35 | files: | 36 | dist/main.js 37 | dist/styles.css 38 | dist/manifest.json 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js/styles.css file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | dist 14 | main.js 15 | styles.css 16 | 17 | # Exclude sourcemaps 18 | *.map 19 | 20 | # obsidian 21 | data.json 22 | 23 | # Exclude macOS Finder (System Explorer) View States 24 | .DS_Store 25 | 26 | # Exclude large mp4 files 27 | assets/*.mp4 -------------------------------------------------------------------------------- /assets/docs/auto-node-resizing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/auto-node-resizing.gif -------------------------------------------------------------------------------- /assets/docs/border-styles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/border-styles.png -------------------------------------------------------------------------------- /assets/docs/collapsible-groups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/collapsible-groups.png -------------------------------------------------------------------------------- /assets/docs/custom-colors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/custom-colors.png -------------------------------------------------------------------------------- /assets/docs/custom-style-attribute-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/custom-style-attribute-example.png -------------------------------------------------------------------------------- /assets/docs/diamond-shape.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /assets/docs/document-shape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/docs/edge-arrow-styles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/edge-arrow-styles.png -------------------------------------------------------------------------------- /assets/docs/edge-metadata-cache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/edge-metadata-cache.png -------------------------------------------------------------------------------- /assets/docs/edge-path-styles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/edge-path-styles.png -------------------------------------------------------------------------------- /assets/docs/edge-pathfinding-methods.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/edge-pathfinding-methods.png -------------------------------------------------------------------------------- /assets/docs/example-custom-node-style.css: -------------------------------------------------------------------------------- 1 | /* @advanced-canvas-node-style 2 | key: validation-state 3 | label: Validation State 4 | options: 5 | - 6 | label: Stateless 7 | value: null 8 | icon: circle-help 9 | 10 | - 11 | label: Approved 12 | value: approved 13 | icon: circle-check 14 | 15 | - 16 | label: Pending 17 | value: pending 18 | icon: circle-dot 19 | 20 | - 21 | label: Rejected 22 | value: rejected 23 | icon: circle-x 24 | */ 25 | .canvas-node[data-validation-state] .canvas-node-content::after { 26 | content: ""; 27 | 28 | position: absolute; 29 | top: 10px; 30 | right: 10px; 31 | 32 | font-size: 1em; 33 | } 34 | 35 | .canvas-node[data-validation-state="approved"] .canvas-node-content::after { 36 | content: "✔️"; 37 | } 38 | 39 | .canvas-node[data-validation-state="pending"] .canvas-node-content::after { 40 | content: "⏳"; 41 | } 42 | 43 | .canvas-node[data-validation-state="rejected"] .canvas-node-content::after { 44 | content: "❌"; 45 | } -------------------------------------------------------------------------------- /assets/docs/flip-edge.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/flip-edge.gif -------------------------------------------------------------------------------- /assets/docs/floating-edge-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/floating-edge-example.png -------------------------------------------------------------------------------- /assets/docs/focus-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/focus-mode.png -------------------------------------------------------------------------------- /assets/docs/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/logo-dark.png -------------------------------------------------------------------------------- /assets/docs/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/logo-light.png -------------------------------------------------------------------------------- /assets/docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | A 16 | 18 | C 19 | -------------------------------------------------------------------------------- /assets/docs/metadata-cache-support.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/metadata-cache-support.png -------------------------------------------------------------------------------- /assets/docs/sample-flowchart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/sample-flowchart.png -------------------------------------------------------------------------------- /assets/docs/sample-portal-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/sample-portal-usage.png -------------------------------------------------------------------------------- /assets/docs/sample-presentation-complex.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/sample-presentation-complex.gif -------------------------------------------------------------------------------- /assets/docs/sample-presentation-complex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/sample-presentation-complex.png -------------------------------------------------------------------------------- /assets/docs/sample-presentation-simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/sample-presentation-simple.gif -------------------------------------------------------------------------------- /assets/docs/sample-presentation-simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/sample-presentation-simple.png -------------------------------------------------------------------------------- /assets/docs/sample-search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/sample-search.gif -------------------------------------------------------------------------------- /assets/docs/stickers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/stickers.png -------------------------------------------------------------------------------- /assets/docs/z-ordering-control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/docs/z-ordering-control.png -------------------------------------------------------------------------------- /assets/flowchart-nodes/database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/flowchart-nodes/database.png -------------------------------------------------------------------------------- /assets/flowchart-nodes/decision.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/flowchart-nodes/decision.png -------------------------------------------------------------------------------- /assets/flowchart-nodes/document.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/flowchart-nodes/document.png -------------------------------------------------------------------------------- /assets/flowchart-nodes/input-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/flowchart-nodes/input-output.png -------------------------------------------------------------------------------- /assets/flowchart-nodes/predefined-process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/flowchart-nodes/predefined-process.png -------------------------------------------------------------------------------- /assets/flowchart-nodes/process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/flowchart-nodes/process.png -------------------------------------------------------------------------------- /assets/flowchart-nodes/reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/flowchart-nodes/reference.png -------------------------------------------------------------------------------- /assets/flowchart-nodes/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Developer-Mike/obsidian-advanced-canvas/2bf4255630a6e732ffdf2c1bf27339c699cd82ea/assets/flowchart-nodes/terminal.png -------------------------------------------------------------------------------- /assets/formats/advanced-json-canvas/README.md: -------------------------------------------------------------------------------- 1 | # An open, more advanced file format compatible with [JSON Canvas](https://github.com/obsidianmd/jsoncanvas) for infinite canvas data. 2 | 3 | The Advanced JSON Canvas format is a structured way to represent a canvas with nodes and edges. It is designed to be extensible and flexible, allowing for various types of nodes and connections between them. It's completely compatible with the standard JSON Canvas format, but adds more features and flexibility. 4 | 5 | Version name consists of two parts: the JSON Canvas version and the Advanced JSON Canvas version. It's in the following order: `-`. For example, `1.0-1.0` means JSON Canvas version 1.0 and Advanced JSON Canvas version 1.0. 6 | 7 | Check out the [specification](spec/1.0-1.0.md) for more details on how to use this format. 8 | 9 | ## About the original **JSON Canvas** format 10 | > Infinite canvas tools are a way to view and organize information spatially, like a digital whiteboard. Infinite canvases encourage freedom and exploration, and have become a popular interface pattern across many apps. 11 | > 12 | > The JSON Canvas format was created to provide longevity, readability, interoperability, and extensibility to data created with infinite canvas apps. The format is designed to be easy to parse and give users [ownership over their data](https://stephango.com/file-over-app). JSON Canvas files use the `.canvas` extension. 13 | > 14 | > JSON Canvas was originally created for [Obsidian](https://obsidian.md/blog/json-canvas/). JSON Canvas can be implemented freely as an import, export, and storage format for any [app or tool](https://github.com/obsidianmd/jsoncanvas/blob/main/docs/apps.md). 15 | -------------------------------------------------------------------------------- /assets/formats/advanced-json-canvas/spec/1.0-1.0.d.ts: -------------------------------------------------------------------------------- 1 | export type CanvasColor = `${number}` | `#${string}` 2 | 3 | export interface CanvasData { 4 | metadata: CanvasMetadata 5 | nodes: AnyCanvasNodeData[] 6 | edges: CanvasEdgeData[] 7 | } 8 | 9 | export interface CanvasMetadata { 10 | version: '1.0-1.0' 11 | frontmatter: { [key: string]: unknown } 12 | startNode?: string 13 | } 14 | 15 | export type CanvasNodeType = 'text' | 'group' | 'file' | 'link' 16 | export interface CanvasNodeData { 17 | id: string 18 | type: CanvasNodeType 19 | 20 | x: number 21 | y: number 22 | width: number 23 | height: number 24 | dynamicHeight?: boolean // AdvancedJsonCanvas 25 | ratio?: number 26 | zIndex?: number // AdvancedJsonCanvas 27 | 28 | color?: CanvasColor 29 | 30 | styleAttributes?: { [key: string]: string | null } // AdvancedJsonCanvas 31 | } 32 | 33 | export type AnyCanvasNodeData = CanvasNodeData | CanvasTextNodeData | CanvasFileNodeData | CanvasLinkNodeData | CanvasGroupNodeData 34 | 35 | export interface CanvasTextNodeData extends CanvasNodeData { 36 | type: 'text' 37 | text: string 38 | } 39 | 40 | export type Subpath = `#${string}` 41 | export interface CanvasFileNodeData extends CanvasNodeData { 42 | type: 'file' 43 | file: string 44 | subpath?: Subpath 45 | 46 | portal?: boolean // AdvancedJsonCanvas 47 | interdimensionalEdges?: CanvasEdgeData[] // AdvancedJsonCanvas 48 | } 49 | 50 | export interface CanvasLinkNodeData extends CanvasNodeData { 51 | type: 'link' 52 | url: string 53 | } 54 | 55 | export type BackgroundStyle = 'cover' | 'ratio' | 'repeat' 56 | export interface CanvasGroupNodeData extends CanvasNodeData { 57 | type: 'group' 58 | label?: string 59 | background?: string 60 | backgroundStyle?: BackgroundStyle 61 | 62 | collapsed?: boolean // AdvancedJsonCanvas 63 | } 64 | 65 | type Side = 'top' | 'right' | 'bottom' | 'left' 66 | type EndType = 'none' | 'arrow' 67 | export interface CanvasEdgeData { 68 | id: string 69 | 70 | fromNode: string 71 | fromSide: Side 72 | fromFloating?: boolean // AdvancedJsonCanvas 73 | fromEnd?: EndType 74 | 75 | toNode: string 76 | toSide: Side 77 | toFloating?: boolean // AdvancedJsonCanvas 78 | toEnd?: EndType 79 | 80 | color?: CanvasColor 81 | label?: string 82 | 83 | styleAttributes?: { [key: string]: string | null } // AdvancedJsonCanvas 84 | } -------------------------------------------------------------------------------- /assets/notes/backlinks-analysis.md: -------------------------------------------------------------------------------- 1 | # Backlinks Analysis 2 | ## Problem 3 | recomputeBacklink(TFile) -> app.vault.getMarkdownFiles 4 | 5 | ### Solution 6 | const recurseChildren = function(e, t) { 7 | for (var n = [e]; n.length > 0; ) { 8 | var i = n.pop(); 9 | if (i && (t(i), i.children)) { 10 | var r = i.children; 11 | n = n.concat(r) 12 | } 13 | } 14 | } 15 | app.vault.getMarkdownFiles = function () { 16 | var markdownFiles = []; 17 | var root = this.getRoot(); 18 | 19 | recurseChildren(root, function (child) { 20 | // Check if the child is an instance of `Sb` and has a ".md" extension 21 | if (child.extension === "md" || child.extension === "canvas") { 22 | markdownFiles.push(child); // Add it to the list of markdown files 23 | } 24 | }); 25 | 26 | return markdownFiles; // Return the collected markdown files 27 | } -------------------------------------------------------------------------------- /assets/notes/graph-analysis.md: -------------------------------------------------------------------------------- 1 | # Graph Analysis 2 | ## Problem 3 | dataEngine.render (app.workspace.getLeavesOfType('graph').first().view.dataEngine.render) -> internal function == "md" 4 | 5 | ### Solution (Unconfirmed) 6 | app.workspace.getLeavesOfType('graph').first().view.dataEngine.render 7 | - patch: metadataCache.getCachedFiles -> return .md extensions instead of .canvas -------------------------------------------------------------------------------- /assets/notes/metadata-cache-analysis.md: -------------------------------------------------------------------------------- 1 | # Metadata Cache Analysis 2 | ## Problem 3 | getCache(path) -> inbuilt function == "md" 4 | 5 | ### Solution 6 | patch getCache(path) 7 | 8 | ## Problem 9 | onCreateOrModify(TFile) -> file.extension == "md" 10 | - No hash is generated for the file 11 | - resolveLinks is not called for the canvas 12 | 13 | ### Solution 14 | patch onCreateOrModify(TFile) 15 | - Generate hash for the file 16 | - Call resolveLinks for the canvas 17 | 18 | ## Problem 19 | resolveLinks(TFile) -> doesn't process the canvas filetype 20 | 21 | ### Solution 22 | patch resolveLinks(TFile) -------------------------------------------------------------------------------- /assets/notes/outgoing-links-analysis.md: -------------------------------------------------------------------------------- 1 | # Outgoing Links Analysis 2 | ## Problem 3 | recomputeLinks() -> this.file.extension == "md" 4 | recomputeUnlinked() -> this.file.extension == "md" 5 | 6 | ### Solution 7 | app.workspace.activeLeaf.view.outgoingLink.file.extension = "md" -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import { sassPlugin } from 'esbuild-sass-plugin'; 5 | import { copy } from "esbuild-plugin-copy"; 6 | 7 | const banner = 8 | `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 10 | if you want to view the source, please visit the github repository of this plugin 11 | */ 12 | `; 13 | 14 | const prod = (process.argv[2] === "production"); 15 | 16 | const context = await esbuild.context({ 17 | banner: { 18 | js: banner, 19 | }, 20 | entryPoints: ["src/main.ts", "src/styles.scss"], 21 | bundle: true, 22 | external: [ 23 | "obsidian", 24 | "electron", 25 | "@codemirror/autocomplete", 26 | "@codemirror/collab", 27 | "@codemirror/commands", 28 | "@codemirror/language", 29 | "@codemirror/lint", 30 | "@codemirror/search", 31 | "@codemirror/state", 32 | "@codemirror/view", 33 | "@lezer/common", 34 | "@lezer/highlight", 35 | "@lezer/lr", 36 | ...builtins 37 | ], 38 | format: "cjs", 39 | target: "es2018", 40 | logLevel: "info", 41 | sourcemap: prod ? false : "inline", 42 | treeShaking: true, 43 | outdir: "dist", 44 | plugins: [ 45 | sassPlugin({}), 46 | copy({ 47 | resolveFrom: "cwd", 48 | watch: !prod, 49 | assets: [ { from: "manifest.json", to: "dist/manifest.json" } ], 50 | }), 51 | ], 52 | }); 53 | 54 | if (prod) { 55 | await context.rebuild(); 56 | process.exit(0); 57 | } else { 58 | await context.watch(); 59 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "advanced-canvas", 3 | "name": "Advanced Canvas", 4 | "version": "5.1.0", 5 | "minAppVersion": "1.1.0", 6 | "description": "Supercharge your canvas experience! Create presentations, flowcharts and more!", 7 | "author": "Developer-Mike", 8 | "authorUrl": "https://github.com/Developer-Mike", 9 | "fundingUrl": "https://ko-fi.com/X8X27IA08", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "src/main.js", 3 | "scripts": { 4 | "dev": "node esbuild.config.mjs", 5 | "build": "node esbuild.config.mjs production" 6 | }, 7 | "devDependencies": { 8 | "@types/node": "^16.11.6", 9 | "@typescript-eslint/eslint-plugin": "5.29.0", 10 | "@typescript-eslint/parser": "5.29.0", 11 | "builtin-modules": "3.3.0", 12 | "esbuild": "^0.25.4", 13 | "obsidian": "latest", 14 | "tslib": "2.4.0", 15 | "typescript": "4.7.4" 16 | }, 17 | "dependencies": { 18 | "esbuild-plugin-copy": "^2.1.1", 19 | "esbuild-sass-plugin": "^3.3.1", 20 | "html-to-image": "^1.11.11", 21 | "json-stable-stringify": "^1.2.1", 22 | "monkey-around": "^2.3.0", 23 | "sass": "^1.70.0", 24 | "tiny-jsonc": "^1.0.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/@types/AdvancedJsonCanvas.d.ts: -------------------------------------------------------------------------------- 1 | export * from "assets/formats/advanced-json-canvas/spec/1.0-1.0" 2 | 3 | import { CanvasData as OriginalCanvasData, AnyCanvasNodeData as OriginalAnyCanvasNodeData, CanvasNodeData, CanvasGroupNodeData as OriginalCanvasGroupNodeData, CanvasFileNodeData as OriginalCanvasFileNodeData, CanvasEdgeData } from "assets/formats/advanced-json-canvas/spec/1.0-1.0" 4 | import { CanvasElementsData } from "./Canvas" 5 | 6 | export type AnyCanvasNodeData = CanvasNodeData | CanvasGroupNodeData | CanvasFileNodeData | OriginalAnyCanvasNodeData 7 | export interface CanvasData extends OriginalCanvasData { 8 | nodes: AnyCanvasNodeData[] 9 | edges: CanvasEdgeData[] 10 | } 11 | 12 | export interface CanvasGroupNodeData extends OriginalCanvasGroupNodeData { 13 | // Intermediate values that are not saved in the canvas 14 | collapsedData?: CanvasElementsData 15 | } 16 | 17 | export interface CanvasFileNodeData extends OriginalCanvasFileNodeData { 18 | // Intermediate values that are not saved in the canvas 19 | isPortalLoaded?: boolean 20 | } -------------------------------------------------------------------------------- /src/@types/BacklinkPlugin.d.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian" 2 | 3 | export default interface Backlink { 4 | recomputeBacklink(file: TFile | null): void 5 | } -------------------------------------------------------------------------------- /src/@types/Canvas.d.ts: -------------------------------------------------------------------------------- 1 | import { App, ItemView, TFile, WorkspaceLeaf } from "obsidian" 2 | import { AnyCanvasNodeData, CanvasData, CanvasEdgeData, CanvasMetadata, CanvasNodeData, EndType, Side } from "./AdvancedJsonCanvas" 3 | 4 | export interface Size { 5 | width: number 6 | height: number 7 | } 8 | 9 | export interface Position { 10 | x: number 11 | y: number 12 | } 13 | 14 | export interface BBox { 15 | minX: number 16 | maxX: number 17 | minY: number 18 | maxY: number 19 | } 20 | 21 | export interface CanvasOptions { 22 | snapToObjects: boolean 23 | snapToGrid: boolean 24 | } 25 | 26 | export interface CanvasElementsData { 27 | nodes: CanvasNodeData[] 28 | edges: CanvasEdgeData[] 29 | } 30 | 31 | export interface CanvasHistory { 32 | data: CanvasElementsData[] 33 | current: number 34 | max: number 35 | 36 | applyHistory: (data: CanvasElementsData) => void 37 | canUndo: () => boolean 38 | undo: () => CanvasElementsData | null 39 | canRedo: () => boolean 40 | redo: () => CanvasElementsData | null 41 | } 42 | 43 | export interface SelectionData extends CanvasElementsData { 44 | center: Position 45 | } 46 | 47 | export interface CanvasConfig { 48 | defaultTextNodeDimensions: Size 49 | defaultFileNodeDimensions: Size 50 | minContainerDimension: number 51 | } 52 | 53 | export interface CanvasView extends ItemView { 54 | _loaded: boolean 55 | file: TFile 56 | canvas: Canvas 57 | leaf: CanvasWorkspaceLeaf 58 | 59 | getViewData(): string 60 | setViewData(data: string): void 61 | 62 | close(): void 63 | 64 | data: string 65 | lastSavedData: string 66 | requestSave(): void 67 | } 68 | 69 | export interface CanvasWorkspaceLeaf extends WorkspaceLeaf { 70 | id: string 71 | rebuildView(): void 72 | } 73 | 74 | export interface CanvasElement { 75 | id: string 76 | 77 | canvas: Canvas 78 | initialized: boolean 79 | isDirty?: boolean // Custom for Change event 80 | 81 | child: { 82 | data: string 83 | 84 | editMode: { 85 | cm: { 86 | dom: HTMLElement 87 | } 88 | } 89 | } 90 | 91 | initialize(): void 92 | setColor(color: string): void 93 | 94 | updateBreakpoint(breakpoint: boolean): void 95 | setIsEditing(editing: boolean): void 96 | getBBox(): BBox 97 | 98 | getData(): CanvasNodeData | CanvasEdgeData 99 | setData(data: CanvasNodeData | CanvasEdgeData): void 100 | } 101 | 102 | export interface CanvasNode extends CanvasElement { 103 | isEditing: boolean 104 | 105 | nodeEl: HTMLElement 106 | contentEl: HTMLElement 107 | isContentMounted?: boolean 108 | 109 | labelEl?: HTMLElement 110 | 111 | x: number 112 | y: number 113 | width: number 114 | height: number 115 | 116 | zIndex: number 117 | /** Move node to the front. */ 118 | updateZIndex(): void 119 | renderZIndex(): void 120 | 121 | color: string 122 | 123 | setData(data: AnyCanvasNodeData, addHistory?: boolean): void 124 | getData(): CanvasNodeData 125 | 126 | onConnectionPointerdown(e: PointerEvent, side: Side): void 127 | 128 | // File node only 129 | file?: TFile 130 | setFile(file: TFile, subpath?: string, force?: boolean): void 131 | setFilePath(filepath: string, subpath: string): void 132 | 133 | // Custom 134 | collapseEl?: HTMLElement 135 | 136 | breakpoint?: number | null 137 | prevX: number 138 | prevY: number 139 | prevWidth: number 140 | prevHeight: number 141 | 142 | currentPortalFile?: string 143 | portalIdMaps?: { 144 | nodeIdMap: { [key: string]: string } 145 | edgeIdMap: { [key: string]: string } 146 | } 147 | 148 | // Custom 149 | setZIndex(value?: number): void 150 | } 151 | 152 | export interface CanvasEdgeEnd { 153 | node: CanvasNode 154 | side: Side 155 | end: EndType 156 | } 157 | 158 | export interface CanvasEdge extends CanvasElement { 159 | label: string 160 | 161 | from: CanvasEdgeEnd 162 | fromLineEnd: { 163 | el: HTMLElement 164 | type: 'arrow' 165 | } 166 | 167 | to: CanvasEdgeEnd 168 | toLineEnd: { 169 | el: HTMLElement 170 | type: 'arrow' 171 | } 172 | 173 | bezier: { 174 | from: Position 175 | to: Position 176 | cp1: Position 177 | cp2: Position 178 | path: string 179 | } 180 | 181 | path: { 182 | interaction: HTMLElement 183 | display: HTMLElement 184 | } 185 | 186 | labelElement: { 187 | edge: CanvasEdge 188 | initialTextState: string 189 | isEditing: boolean 190 | textareaEl: HTMLElement 191 | wrapperEl: HTMLElement 192 | 193 | render(): void 194 | } 195 | 196 | lineGroupEl: HTMLElement 197 | lineEndGroupEl: HTMLElement 198 | getCenter(): Position 199 | render(): void 200 | updatePath(): void 201 | onConnectionPointerdown(e: PointerEvent): void 202 | 203 | setData(data: CanvasEdgeData, addHistory?: boolean): void 204 | getData(): CanvasEdgeData 205 | 206 | // Custom 207 | center?: Position 208 | } 209 | 210 | export interface NodeInteractionLayer { 211 | canvas: Canvas 212 | interactionEl: HTMLElement 213 | setTarget(node: CanvasNode): void 214 | } 215 | 216 | export interface CanvasPopupMenu { 217 | canvas: Canvas 218 | menuEl: HTMLElement 219 | render(): void 220 | } 221 | 222 | export interface Canvas { 223 | app: App 224 | 225 | view: CanvasView 226 | config: CanvasConfig 227 | options: CanvasOptions 228 | 229 | metadata: CanvasMetadata 230 | 231 | unload(): void 232 | /** 233 | * @deprecated Use getData instead -> Can be outdated 234 | */ 235 | data: CanvasData 236 | getData(): CanvasData 237 | setData(data: CanvasData): void 238 | /** Basically setData (if clearCanvas == true), but without modifying the history */ 239 | importData(data: CanvasElementsData, clearCanvas?: boolean, /* custom */ silent?: boolean): void 240 | clear(): void 241 | 242 | nodes: Map 243 | edges: Map 244 | getEdgesForNode(node: CanvasNode): CanvasEdge[] 245 | 246 | edgeFrom: { 247 | data: Map> 248 | add: (node: CanvasNode, edge: CanvasEdge) => void 249 | get: (node: CanvasNode) => Set | undefined 250 | } 251 | edgeTo: { 252 | data: Map> 253 | add: (node: CanvasNode, edge: CanvasEdge) => void 254 | get: (node: CanvasNode) => Set | undefined 255 | } 256 | 257 | dirty: Set 258 | markDirty(element: CanvasElement): void 259 | markMoved(element: CanvasElement): void 260 | 261 | wrapperEl: HTMLElement 262 | canvasEl: HTMLElement 263 | menu: CanvasPopupMenu 264 | cardMenuEl: HTMLElement 265 | canvasControlsEl: HTMLElement 266 | quickSettingsButton: HTMLElement 267 | nodeInteractionLayer: NodeInteractionLayer 268 | 269 | canvasRect: DOMRect 270 | getViewportBBox(): BBox 271 | setViewport(tx: number, ty: number, tZoom: number): void 272 | 273 | viewportChanged: boolean 274 | markViewportChanged(): void 275 | 276 | x: number 277 | y: number 278 | zoom: number 279 | zoomCenter: Position | null 280 | zoomBreakpoint: number 281 | 282 | tx: number 283 | ty: number 284 | tZoom: number 285 | screenshotting: boolean 286 | 287 | isDragging: boolean 288 | setDragging(dragging: boolean): void 289 | 290 | zIndexCounter: number 291 | 292 | pointer: Position 293 | 294 | zoomToFit(): void 295 | zoomToSelection(): void 296 | zoomToBbox(bbox: BBox): void 297 | 298 | posFromClient(clientPos: Position): Position 299 | 300 | readonly: boolean 301 | setReadonly(readonly: boolean): void 302 | 303 | selection: Set 304 | getSelectionData(): SelectionData 305 | updateSelection(update: () => void): void 306 | selectOnly(element: CanvasElement): void 307 | deselectAll(): void 308 | 309 | toggleObjectSnapping(enabled: boolean): void 310 | dragTempNode(dragEvent: any, nodeSize: Size, onDropped: (position: Position) => void): void 311 | 312 | createTextNode(options: { [key: string]: any }): CanvasNode 313 | createGroupNode(options: { [key: string]: any }): CanvasNode 314 | createFileNode(options: { [key: string]: any }): CanvasNode 315 | createFileNodes(filepaths: string[], position: Position): CanvasNode[] 316 | createLinkNode(options: { [key: string]: any }): CanvasNode 317 | 318 | addNode(node: CanvasNode): void 319 | removeNode(node: CanvasNode): void 320 | addEdge(edge: CanvasEdge): void 321 | removeEdge(edge: CanvasEdge): void 322 | 323 | getContainingNodes(bbox: BBox): CanvasNode[] 324 | getViewportNodes(): CanvasNode[] 325 | 326 | history: CanvasHistory 327 | pushHistory(data: CanvasElementsData): void 328 | undo(): void 329 | redo(): void 330 | 331 | posFromEvt(event: MouseEvent): Position 332 | onDoubleClick(event: MouseEvent): void 333 | handleCopy(e: ClipboardEvent): void 334 | 335 | handlePaste(): void 336 | requestSave(): void 337 | 338 | onResize(): void 339 | 340 | // Custom 341 | searchEl?: HTMLElement 342 | zoomToRealBbox(bbox: BBox): void 343 | isClearing?: boolean 344 | isCopying?: boolean 345 | isPasting?: boolean 346 | lockedX: number 347 | lockedY: number 348 | lockedZoom: number 349 | } -------------------------------------------------------------------------------- /src/@types/CustomWorkspaceEvents.d.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from "obsidian" 2 | import { BBox, Canvas, CanvasEdge, CanvasEdgeEnd, CanvasElement, CanvasNode, CanvasView, Position, SelectionData } from "./Canvas" 3 | import { CanvasData } from "./AdvancedJsonCanvas" 4 | 5 | export interface EventRef { 6 | fn: (...args: any) => any 7 | } 8 | 9 | export interface CustomWorkspaceEvents { 10 | // Plugin events 11 | 'advanced-canvas:settings-changed': () => void 12 | 13 | // Built-in canvas events 14 | 'canvas:selection-menu': (menu: Menu, canvas: Canvas) => void 15 | 'canvas:node-menu': (menu: Menu, node: CanvasNode) => void 16 | 'canvas:edge-menu': (menu: Menu, canvas: Canvas) => void 17 | 'canvas:node-connection-drop-menu': (menu: Menu, canvas: Canvas) => void 18 | 19 | // Custom canvas events 20 | /** Fired when a new canvas gets loaded */ 21 | 'advanced-canvas:canvas-changed': (canvas: Canvas) => void 22 | /** Fired before the canvas view gets unloaded */ 23 | 'advanced-canvas:canvas-view-unloaded:before': (view: CanvasView) => void 24 | /** Fired when the canvas' metadata gets changed */ 25 | 'advanced-canvas:canvas-metadata-changed': (canvas: Canvas) => void 26 | /** Fired before the viewport gets changed */ 27 | 'advanced-canvas:viewport-changed:before': (canvas: Canvas) => void 28 | /** Fired after the viewport gets changed */ 29 | 'advanced-canvas:viewport-changed:after': (canvas: Canvas) => void 30 | /** Fired when a node gets moved */ 31 | 'advanced-canvas:node-moved': (canvas: Canvas, node: CanvasNode, usingKeyboard: boolean) => void 32 | /** Fired when a node gets resized */ 33 | 'advanced-canvas:node-resized': (canvas: Canvas, node: CanvasNode) => void 34 | /** Fired when the canvas gets double-clicked */ 35 | 'advanced-canvas:double-click': (canvas: Canvas, event: MouseEvent, preventDefault: { value: boolean }) => void 36 | /** Fired when the dragging state of the canvas changes */ 37 | 'advanced-canvas:dragging-state-changed': (canvas: Canvas, isDragging: boolean) => void 38 | /** Fired when a new node gets created */ 39 | 'advanced-canvas:node-created': (canvas: Canvas, node: CanvasNode) => void 40 | /** Fired when a new edge gets created */ 41 | 'advanced-canvas:edge-created': (canvas: Canvas, edge: CanvasEdge) => void 42 | /** Fired when a new node gets added */ 43 | 'advanced-canvas:node-added': (canvas: Canvas, node: CanvasNode) => void 44 | /** Fired when a new edge gets added */ 45 | 'advanced-canvas:edge-added': (canvas: Canvas, edge: CanvasEdge) => void 46 | /** Fired when any node gets changed */ 47 | 'advanced-canvas:node-changed': (canvas: Canvas, node: CanvasNode) => void 48 | /** Fired when any edge gets changed */ 49 | 'advanced-canvas:edge-changed': (canvas: Canvas, edge: CanvasEdge) => void 50 | /** Fired when the text content of a node gets changed (While typing) */ 51 | 'advanced-canvas:node-text-content-changed': (canvas: Canvas, node: CanvasNode, viewUpdate: any) => void 52 | /** Fired before an existing edge tries to get dragged */ 53 | 'advanced-canvas:edge-connection-try-dragging:before': (canvas: Canvas, edge: CanvasEdge, event: PointerEvent, cancelRef: { value: boolean }) => void 54 | /** Fired before an edge gets dragged */ 55 | 'advanced-canvas:edge-connection-dragging:before': (canvas: Canvas, edge: CanvasEdge, event: PointerEvent, newEdge: boolean, side: 'from' | 'to', previousEnds?: { from: CanvasEdgeEnd, to: CanvasEdgeEnd }) => void 56 | /** Fired after an edge gets dragged */ 57 | 'advanced-canvas:edge-connection-dragging:after': (canvas: Canvas, edge: CanvasEdge, event: PointerEvent, newEdge: boolean, side: 'from' | 'to', previousEnds?: { from: CanvasEdgeEnd, to: CanvasEdgeEnd }) => void 58 | /** Fired when a node gets deleted */ 59 | 'advanced-canvas:node-removed': (canvas: Canvas, node: CanvasNode) => void 60 | /** Fired when an edge gets deleted */ 61 | 'advanced-canvas:edge-removed': (canvas: Canvas, edge: CanvasEdge) => void 62 | /** Fired when a selection of the canvas gets copied */ 63 | 'advanced-canvas:copy': (canvas: Canvas, selectionData: SelectionData) => void 64 | /** Fired when the editing state of a node changes */ 65 | 'advanced-canvas:node-editing-state-changed': (canvas: Canvas, node: CanvasNode, isEditing: boolean) => void 66 | /** Fired when the breakpoint of a node changes (decides if the node's content should be loaded) */ 67 | 'advanced-canvas:node-breakpoint-changed': (canvas: Canvas, node: CanvasNode, shouldBeLoaded: { value: boolean }) => void 68 | /** Fired when the bounding box of a node gets requested (e.g. for the edge path or when dragging a group) */ 69 | 'advanced-canvas:node-bbox-requested': (canvas: Canvas, node: CanvasNode, bbox: BBox) => void 70 | /** Fired when the center of an edge gets requested (e.g. for the edge label position) */ 71 | 'advanced-canvas:edge-center-requested': (canvas: Canvas, edge: CanvasEdge, position: Position) => void 72 | /** Fired when the nodes inside a bounding box get requested */ 73 | 'advanced-canvas:containing-nodes-requested': (canvas: Canvas, bbox: BBox, nodes: CanvasNode[]) => void 74 | /** Fired when the selection of the canvas changes */ 75 | 'advanced-canvas:selection-changed': (canvas: Canvas, oldSelection: Set, updateSelection: (update: () => void) => void) => void 76 | /** Fired before the canvas gets zoomed to a bounding box (e.g. zoom to selection, zoom to fit all) */ 77 | 'advanced-canvas:zoom-to-bbox:before': (canvas: Canvas, bbox: BBox) => void 78 | /** Fired after the canvas gets zoomed to a bounding box (e.g. zoom to selection, zoom to fit all) */ 79 | 'advanced-canvas:zoom-to-bbox:after': (canvas: Canvas, bbox: BBox) => void 80 | /** Fired when the a node popup menu gets created (Not firing multiple times if it gets moved between nodes of the same type) */ 81 | 'advanced-canvas:popup-menu-created': (canvas: Canvas) => void 82 | /** Fired when a node gets hovered over */ 83 | 'advanced-canvas:node-interaction': (canvas: Canvas, node: CanvasNode) => void 84 | /** Fired when undo gets called */ 85 | 'advanced-canvas:undo': (canvas: Canvas) => void 86 | /** Fired when redo gets called */ 87 | 'advanced-canvas:redo': (canvas: Canvas) => void 88 | /** Fired when the readonly state of the canvas changes */ 89 | 'advanced-canvas:readonly-changed': (canvas: Canvas, readonly: boolean) => void 90 | /** Fired when the canvas data gets requested */ 91 | 'advanced-canvas:data-requested': (canvas: Canvas, data: CanvasData) => void 92 | /** Fired before the canvas data gets set */ 93 | 'advanced-canvas:data-loaded:before': (canvas: Canvas, data: CanvasData, setData: (data: CanvasData) => void) => void 94 | /** Fired after the canvas data gets set */ 95 | 'advanced-canvas:data-loaded:after': (canvas: Canvas, data: CanvasData, setData: (data: CanvasData) => void) => void 96 | /** Fired before the canvas gets saved */ 97 | 'advanced-canvas:canvas-saved:before': (canvas: Canvas) => void 98 | /** Fired after the canvas gets saved */ 99 | 'advanced-canvas:canvas-saved:after': (canvas: Canvas) => void 100 | } -------------------------------------------------------------------------------- /src/@types/Obsidian.d.ts: -------------------------------------------------------------------------------- 1 | import { CachedMetadata, EmbedCache, FrontMatterCache, FrontmatterLinkCache, LinkCache, Pos, TagCache } from "obsidian" 2 | import { CustomWorkspaceEvents } from "./CustomWorkspaceEvents" 3 | import SuggestManager from "./SuggestManager" 4 | 5 | export * from "obsidian" 6 | 7 | declare module "obsidian" { 8 | export default interface App { 9 | /** @public */ 10 | keymap: Keymap 11 | /** @public */ 12 | scope: Scope 13 | /** @public */ 14 | vault: ExtendedVault 15 | /** @public */ 16 | fileManager: FileManager 17 | /** 18 | * The last known user interaction event, to help commands find out what modifier keys are pressed. 19 | * @public 20 | */ 21 | lastEvent: UserEvent | null 22 | 23 | commands: any 24 | internalPlugins: any 25 | 26 | viewRegistry: any 27 | embedRegistry: EmbedRegistry 28 | 29 | /** @public */ 30 | metadataCache: ExtendedMetadataCache 31 | /** @public */ // exclude only the on method that takes a string and not a specific event name 32 | workspace: Omit, 'trigger'> & { 33 | on(name: K, callback: (...args: Parameters) => void): EventRef 34 | trigger(name: K, ...args: Parameters): void 35 | 36 | // Inbuilt 37 | on(name: 'quick-preview', callback: (file: TFile, data: string) => any, ctx?: any): EventRef 38 | on(name: 'resize', callback: () => any, ctx?: any): EventRef 39 | on(name: 'active-leaf-change', callback: (leaf: WorkspaceLeaf | null) => any, ctx?: any): EventRef 40 | on(name: 'file-open', callback: (file: TFile | null) => any, ctx?: any): EventRef 41 | on(name: 'layout-change', callback: () => any, ctx?: any): EventRef 42 | on(name: 'window-open', callback: (win: WorkspaceWindow, window: Window) => any, ctx?: any): EventRef 43 | on(name: 'window-close', callback: (win: WorkspaceWindow, window: Window) => any, ctx?: any): EventRef 44 | on(name: 'css-change', callback: () => any, ctx?: any): EventRef 45 | on(name: 'file-menu', callback: (menu: Menu, file: TAbstractFile, source: string, leaf?: WorkspaceLeaf) => any, ctx?: any): EventRef 46 | on(name: 'files-menu', callback: (menu: Menu, files: TAbstractFile[], source: string, leaf?: WorkspaceLeaf) => any, ctx?: any): EventRef 47 | on(name: 'url-menu', callback: (menu: Menu, url: string) => any, ctx?: any): EventRef 48 | on(name: 'editor-menu', callback: (menu: Menu, editor: Editor, info: MarkdownView | MarkdownFileInfo) => any, ctx?: any): EventRef 49 | on(name: 'editor-change', callback: (editor: Editor, info: MarkdownView | MarkdownFileInfo) => any, ctx?: any): EventRef 50 | on(name: 'editor-paste', callback: (evt: ClipboardEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo) => any, ctx?: any): EventRef 51 | on(name: 'editor-drop', callback: (evt: DragEvent, editor: Editor, info: MarkdownView | MarkdownFileInfo) => any, ctx?: any): EventRef 52 | on(name: 'quit', callback: (tasks: Tasks) => any, ctx?: any): EventRef 53 | } 54 | } 55 | 56 | export interface ExtendedWorkspace extends Workspace { 57 | editorSuggest: { 58 | suggests: { suggestManager?: SuggestManager }[] 59 | } 60 | } 61 | 62 | export interface ExtendedVault extends Vault { 63 | getMarkdownFiles: () => TFile[] 64 | 65 | // Custom 66 | recurseChildrenAC: (origin: TAbstractFile, traverse: (file: TAbstractFile) => void) => void 67 | } 68 | 69 | export interface EmbedRegistry { 70 | embedByExtension: { [extension: string]: (context: EmbedContext, file: TFile, subpath?: string) => Component } 71 | } 72 | 73 | export interface EmbedContext { 74 | app: App 75 | 76 | containerEl: HTMLElement 77 | sourcePath?: string 78 | linktext?: string 79 | 80 | displayMode?: boolean 81 | showInline?: boolean 82 | depth?: number 83 | } 84 | 85 | export interface ExtendedMetadataCache extends MetadataCache { 86 | vault: ExtendedVault 87 | 88 | workQueue: { 89 | promise: Promise 90 | } 91 | 92 | fileCache: FileCache 93 | metadataCache: MetadataCacheMap 94 | resolvedLinks: ResolvedLinks 95 | 96 | computeMetadataAsync: (buffer: ArrayBuffer) => Promise 97 | 98 | computeFileMetadataAsync: (file: TFile) => void 99 | saveFileCache: (filepath: string, cache: FileCacheEntry) => void 100 | linkResolver: () => void 101 | resolveLinks: (filepath: string, /* custom */ cachedContent: any) => void 102 | 103 | // Custom 104 | registerInternalLinkAC: (canvasName: string, from: string, to: string) => void 105 | } 106 | } 107 | 108 | export interface FileCache { 109 | [path: string]: FileCacheEntry 110 | } 111 | 112 | export interface FileCacheEntry { 113 | hash: string 114 | mtime: number 115 | size: number 116 | } 117 | 118 | export interface MetadataCacheMap { 119 | [hash: string]: ExtendedCachedMetadata 120 | } 121 | 122 | export interface ExtendedCachedMetadata extends CachedMetadata { 123 | links?: LinkCache[] 124 | embeds?: EmbedCache[] 125 | nodes?: NodesCache 126 | v: number 127 | } 128 | 129 | export interface NodesCache { 130 | [nodeId: string]: CachedMetadata 131 | } 132 | 133 | export interface ResolvedLinks { 134 | [path: string]: { 135 | [link: string]: number 136 | } 137 | } -------------------------------------------------------------------------------- /src/@types/OutgoingLinkPlugin.d.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian" 2 | 3 | export default interface OutgoingLink { 4 | file: TFile 5 | 6 | recomputeLinks(): void 7 | recomputeUnlinked(): void 8 | } -------------------------------------------------------------------------------- /src/@types/PropertiesPlugin.d.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian" 2 | 3 | export default interface PropertiesView { 4 | app: App 5 | file: TFile 6 | modifyingFile: TFile 7 | 8 | rawFrontmatter: string 9 | frontmatter: { [key: string]: any } 10 | 11 | isSupportedFile: (file?: TFile) => boolean 12 | updateFrontmatter: (file: TFile, content: string) => { [ key: string ]: any } | null 13 | saveFrontmatter: (frontmatter: { [key: string]: any }) => void 14 | } -------------------------------------------------------------------------------- /src/@types/SearchPlugin.d.ts: -------------------------------------------------------------------------------- 1 | import { App, CachedMetadata } from "./Obsidian" 2 | 3 | export default interface SearchView { 4 | searchQuery: SearchQuery 5 | startSearch: () => void 6 | } 7 | 8 | export interface SearchQuery { 9 | app: App 10 | _match: (data: MatchData) => any 11 | } 12 | 13 | export interface MatchData { 14 | strings: { 15 | filename: string 16 | filepath: string 17 | content: string 18 | } 19 | 20 | caseSensitive: boolean 21 | original: string 22 | 23 | keys: string[] 24 | cache: CachedMetadata | null 25 | 26 | data: any 27 | } -------------------------------------------------------------------------------- /src/@types/Settings.d.ts: -------------------------------------------------------------------------------- 1 | import { StyleAttribute } from "src/canvas-extensions/advanced-styles/style-config" 2 | import SettingsManager from "src/settings" 3 | 4 | export interface SettingsHeading { 5 | label: string 6 | description: string 7 | infoSection?: string 8 | disableToggle?: boolean 9 | 10 | children: { [id: string]: Setting } 11 | } 12 | 13 | export interface Setting { 14 | label: string 15 | description: string 16 | type: null | 'text' | 'number' | 'dimension' | 'boolean' | 'dropdown' | 'button' | 'styles' 17 | 18 | parse?: (value: any) => any 19 | } 20 | 21 | export interface StyleAttributesSetting extends Setting { 22 | type: 'styles' 23 | description: "" 24 | getParameters: (settingsManager: SettingsManager) => StyleAttribute[] 25 | } 26 | 27 | export interface TextSetting extends Setting { 28 | type: 'text' 29 | } 30 | 31 | export interface NumberSetting extends Setting { 32 | type: 'number' 33 | parse: (value: string) => number 34 | } 35 | 36 | export interface DimensionSetting extends Setting { 37 | type: 'dimension' 38 | parse: (value: [string, string]) => [number, number] 39 | } 40 | 41 | export interface BooleanSetting extends Setting { 42 | type: 'boolean' 43 | } 44 | 45 | export interface DropdownSetting extends Setting { 46 | type: 'dropdown' 47 | options: { [id: string]: string } 48 | } 49 | 50 | export interface ButtonSetting extends Setting { 51 | type: 'button' 52 | onClick: () => any 53 | } -------------------------------------------------------------------------------- /src/@types/SuggestManager.d.ts: -------------------------------------------------------------------------------- 1 | import { App, CachedMetadata, TFile } from "./Obsidian" 2 | 3 | export default interface SuggestManager { 4 | app: App 5 | 6 | getSourcePath: () => string 7 | getHeadingSuggestions: (context: App, path: string, subpath: string) => Promise 8 | } 9 | 10 | export interface Suggestion { 11 | file: TFile 12 | heading: string 13 | level: number 14 | matches: [number, number][] 15 | 16 | path: string 17 | subpath: string 18 | 19 | score: number 20 | type: "heading" 21 | } -------------------------------------------------------------------------------- /src/advanced-canvas-embed.ts: -------------------------------------------------------------------------------- 1 | import { Component, EmbedContext, MarkdownRenderer, TFile } from "obsidian" 2 | import { CanvasData, CanvasFileNodeData, CanvasGroupNodeData, CanvasTextNodeData } from "./@types/AdvancedJsonCanvas" 3 | 4 | export default class AdvancedCanvasEmbed extends Component { 5 | private context: EmbedContext 6 | private file: TFile 7 | private subpath: string | undefined 8 | 9 | constructor(context: EmbedContext, file: TFile, subpath?: string) { 10 | super() 11 | 12 | this.context = context 13 | this.file = file 14 | this.subpath = subpath 15 | 16 | if (!subpath) console.warn("AdvancedCanvasEmbed: No subpath provided. This embed will not work as expected.") 17 | } 18 | 19 | onload() { 20 | this.context.app.vault.on("modify", this.onModifyCallback) 21 | } 22 | 23 | onunload() { 24 | this.context.app.vault.off("modify", this.onModifyCallback) 25 | } 26 | 27 | private onModifyCallback = (file: TFile) => { 28 | if (file.path !== this.file.path) return 29 | this.loadFile() 30 | } 31 | 32 | async loadFile() { 33 | if (!this.subpath) return 34 | const nodeId = this.subpath.replace(/^#/, "") 35 | 36 | const canvasContent = await this.context.app.vault.cachedRead(this.file) 37 | if (!canvasContent) return console.warn("AdvancedCanvasEmbed: No canvas content found.") 38 | 39 | const canvasJson = JSON.parse(canvasContent) as CanvasData 40 | const canvasNode = canvasJson.nodes.find(node => node.id === nodeId) 41 | 42 | // Show node not found error 43 | if (!canvasNode) { 44 | this.context.containerEl.classList.add("mod-empty") 45 | this.context.containerEl.textContent = "Node not found" 46 | 47 | return 48 | } 49 | 50 | // Handle different node types 51 | let nodeContent = "" 52 | if (canvasNode.type === "text") nodeContent = (canvasNode as CanvasTextNodeData).text 53 | else if (canvasNode.type === "group") nodeContent = `**Group Node:** ${(canvasNode as CanvasGroupNodeData).label}` 54 | else if (canvasNode.type === "file") nodeContent = `**File Node:** ${(canvasNode as CanvasFileNodeData).file}` 55 | 56 | this.context.containerEl.classList.add("markdown-embed") 57 | this.context.containerEl.empty() // Clear the container (for re-rendering on changes) 58 | MarkdownRenderer.render(this.context.app, nodeContent, this.context.containerEl, this.file.path, this) 59 | } 60 | } -------------------------------------------------------------------------------- /src/canvas-extensions/advanced-styles/edge-pathfinding-methods/edge-pathfinding-method.ts: -------------------------------------------------------------------------------- 1 | import { Side } from "src/@types/AdvancedJsonCanvas" 2 | import { BBox, Canvas, Position } from "src/@types/Canvas" 3 | import AdvancedCanvasPlugin from "src/main" 4 | 5 | export default abstract class EdgePathfindingMethod { 6 | constructor( 7 | protected plugin: AdvancedCanvasPlugin, 8 | protected canvas: Canvas, 9 | protected fromNodeBBox: BBox, 10 | protected fromPos: Position, 11 | protected fromBBoxSidePos: Position, 12 | protected fromSide: Side, 13 | protected toNodeBBox: BBox, 14 | protected toPos: Position, 15 | protected toBBoxSidePos: Position, 16 | protected toSide: Side 17 | ) {} 18 | 19 | abstract getPath(): EdgePath | null 20 | } 21 | 22 | export interface EdgePath { 23 | svgPath: string 24 | center: Position 25 | rotateArrows: boolean 26 | } -------------------------------------------------------------------------------- /src/canvas-extensions/advanced-styles/edge-pathfinding-methods/pathfinding-direct.ts: -------------------------------------------------------------------------------- 1 | import SvgPathHelper from "src/utils/svg-path-helper" 2 | import EdgePathfindingMethod, { EdgePath } from "./edge-pathfinding-method" 3 | 4 | export default class EdgePathfindingDirect extends EdgePathfindingMethod { 5 | getPath(): EdgePath { 6 | return { 7 | svgPath: SvgPathHelper.pathArrayToSvgPath([this.fromPos, this.toPos]), 8 | center: { 9 | x: (this.fromPos.x + this.toPos.x) / 2, 10 | y: (this.fromPos.y + this.toPos.y) / 2 11 | }, 12 | rotateArrows: true 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/canvas-extensions/advanced-styles/edge-styles.ts: -------------------------------------------------------------------------------- 1 | import { BBox, Canvas, CanvasEdge, CanvasNode, Position } from "src/@types/Canvas" 2 | import BBoxHelper from "src/utils/bbox-helper" 3 | import CanvasHelper from "src/utils/canvas-helper" 4 | import CanvasExtension from "../canvas-extension" 5 | import EdgePathfindingMethod from "./edge-pathfinding-methods/edge-pathfinding-method" 6 | import EdgePathfindingAStar from "./edge-pathfinding-methods/pathfinding-a-star" 7 | import EdgePathfindingDirect from "./edge-pathfinding-methods/pathfinding-direct" 8 | import EdgePathfindingSquare from "./edge-pathfinding-methods/pathfinding-square" 9 | import { BUILTIN_EDGE_STYLE_ATTRIBUTES, StyleAttribute, styleAttributeValidator } from "./style-config" 10 | import CssStylesConfigManager from "src/managers/css-styles-config-manager" 11 | 12 | const EDGE_PATHFINDING_METHODS: { [key: string]: typeof EdgePathfindingMethod } = { 13 | 'direct': EdgePathfindingDirect, 14 | 'square': EdgePathfindingSquare, 15 | 'a-star': EdgePathfindingAStar 16 | } 17 | 18 | const MAX_LIVE_UPDATE_SELECTION_SIZE = 5 19 | export default class EdgeStylesExtension extends CanvasExtension { 20 | cssStylesManager: CssStylesConfigManager 21 | 22 | isEnabled() { return 'edgesStylingFeatureEnabled' as const } 23 | 24 | init() { 25 | this.cssStylesManager = new CssStylesConfigManager(this.plugin, 'advanced-canvas-edge-style', styleAttributeValidator) 26 | 27 | this.plugin.registerEvent(this.plugin.app.workspace.on( 28 | 'advanced-canvas:popup-menu-created', 29 | (canvas: Canvas) => this.onPopupMenuCreated(canvas) 30 | )) 31 | 32 | this.plugin.registerEvent(this.plugin.app.workspace.on( 33 | 'advanced-canvas:edge-changed', 34 | (canvas: Canvas, edge: CanvasEdge) => this.onEdgeChanged(canvas, edge) 35 | )) 36 | 37 | this.plugin.registerEvent(this.plugin.app.workspace.on( 38 | 'advanced-canvas:edge-center-requested', 39 | (canvas: Canvas, edge: CanvasEdge, center: Position) => this.onEdgeCenterRequested(canvas, edge, center) 40 | )) 41 | 42 | this.plugin.registerEvent(this.plugin.app.workspace.on( 43 | 'advanced-canvas:node-added', 44 | (canvas: Canvas, node: CanvasNode) => { 45 | if (canvas.dirty.size > 1 && !canvas.isPasting) return // Skip if multiple nodes are added at once (e.g. on initial load) 46 | 47 | this.updateAllEdgesInArea(canvas, node.getBBox()) 48 | } 49 | )) 50 | 51 | this.plugin.registerEvent(this.plugin.app.workspace.on( 52 | 'advanced-canvas:node-moved', 53 | // Only update edges this way if a node got moved with the arrow keys 54 | (canvas: Canvas, node: CanvasNode, keyboard: boolean) => node.initialized && keyboard ? this.updateAllEdgesInArea(canvas, node.getBBox()) : void 0 55 | )) 56 | 57 | this.plugin.registerEvent(this.plugin.app.workspace.on( 58 | 'advanced-canvas:node-removed', 59 | (canvas: Canvas, node: CanvasNode) => this.updateAllEdgesInArea(canvas, node.getBBox()) 60 | )) 61 | 62 | this.plugin.registerEvent(this.plugin.app.workspace.on( 63 | 'advanced-canvas:dragging-state-changed', 64 | (canvas: Canvas, isDragging: boolean) => { 65 | if (isDragging) return 66 | 67 | const selectedNodes = canvas.getSelectionData().nodes 68 | .map(nodeData => canvas.nodes.get(nodeData.id)) 69 | .filter(node => node !== undefined) as CanvasNode[] 70 | const selectedNodeBBoxes = selectedNodes.map(node => node.getBBox()) 71 | const selectedNodeBBox = BBoxHelper.combineBBoxes(selectedNodeBBoxes) 72 | 73 | this.updateAllEdgesInArea(canvas, selectedNodeBBox) 74 | } 75 | )) 76 | } 77 | 78 | // Skip if isDragging and setting isn't enabled and not connecting an edge 79 | private shouldUpdateEdge(canvas: Canvas): boolean { 80 | return !canvas.isDragging || this.plugin.settings.getSetting('edgeStyleUpdateWhileDragging') || canvas.canvasEl.hasClass('is-connecting') 81 | } 82 | 83 | private onPopupMenuCreated(canvas: Canvas): void { 84 | const selectedEdges = [...canvas.selection].filter((item: any) => item.path !== undefined) as CanvasEdge[] 85 | if (canvas.readonly || selectedEdges.length === 0 || selectedEdges.length !== canvas.selection.size) 86 | return 87 | 88 | CanvasHelper.addStyleAttributesToPopup( 89 | this.plugin, canvas, [...BUILTIN_EDGE_STYLE_ATTRIBUTES, /* Legacy */ ...this.plugin.settings.getSetting('customEdgeStyleAttributes'), ...this.cssStylesManager.getStyles()], 90 | selectedEdges[0].getData().styleAttributes ?? {}, 91 | (attribute, value) => this.setStyleAttributeForSelection(canvas, attribute, value) 92 | ) 93 | } 94 | 95 | private setStyleAttributeForSelection(canvas: Canvas, attribute: StyleAttribute, value: string | null): void { 96 | const selectedEdges = [...canvas.selection].filter((item: any) => item.path !== undefined) as CanvasEdge[] 97 | 98 | for (const edge of selectedEdges) { 99 | const edgeData = edge.getData() 100 | 101 | edge.setData({ 102 | ...edgeData, 103 | styleAttributes: { 104 | ...edgeData.styleAttributes, 105 | [attribute.key]: value 106 | } 107 | }) 108 | } 109 | 110 | canvas.pushHistory(canvas.getData()) 111 | } 112 | 113 | private updateAllEdgesInArea(canvas: Canvas, bbox: BBox) { 114 | if (!this.shouldUpdateEdge(canvas)) return 115 | 116 | for (const edge of canvas.edges.values()) { 117 | if (!BBoxHelper.isColliding(edge.getBBox(), bbox)) continue 118 | 119 | canvas.markDirty(edge) 120 | } 121 | } 122 | 123 | private onEdgeChanged(canvas: Canvas, edge: CanvasEdge) { 124 | // Skip if edge isn't dirty or selected 125 | if (!canvas.dirty.has(edge) && !canvas.selection.has(edge)) return 126 | 127 | if (!this.shouldUpdateEdge(canvas)) { 128 | const tooManySelected = canvas.selection.size > MAX_LIVE_UPDATE_SELECTION_SIZE 129 | if (tooManySelected) return 130 | 131 | const groupNodesSelected = [...canvas.selection].some((item: any) => item.getData()?.type === 'group') 132 | if (groupNodesSelected) return 133 | } 134 | 135 | const edgeData = edge.getData() 136 | 137 | // Reset path to default 138 | if (!edge.bezier) return 139 | edge.center = undefined 140 | edge.updatePath() 141 | 142 | // Set pathfinding method 143 | const pathfindingMethod = edgeData.styleAttributes?.pathfindingMethod 144 | if (pathfindingMethod && pathfindingMethod in EDGE_PATHFINDING_METHODS) { 145 | const fromNodeBBox = edge.from.node.getBBox() 146 | const fromBBoxSidePos = BBoxHelper.getCenterOfBBoxSide(fromNodeBBox, edge.from.side) 147 | const fromPos = edge.from.end === 'none' ? 148 | fromBBoxSidePos : 149 | edge.bezier.from 150 | 151 | const toNodeBBox = edge.to.node.getBBox() 152 | const toBBoxSidePos = BBoxHelper.getCenterOfBBoxSide(toNodeBBox, edge.to.side) 153 | const toPos = edge.to.end === 'none' ? 154 | toBBoxSidePos : 155 | edge.bezier.to 156 | 157 | const path = new (EDGE_PATHFINDING_METHODS[pathfindingMethod] as any)( 158 | this.plugin, 159 | canvas, 160 | fromNodeBBox, fromPos, fromBBoxSidePos, edge.from.side, 161 | toNodeBBox, toPos, toBBoxSidePos, edge.to.side 162 | ).getPath() 163 | if (!path) return 164 | 165 | edge.center = path.center 166 | edge.path.interaction.setAttr("d", path?.svgPath) 167 | edge.path.display.setAttr("d", path?.svgPath) 168 | } 169 | 170 | // Update label position 171 | edge.labelElement?.render() 172 | 173 | // Set arrow polygon 174 | const arrowPolygonPoints = this.getArrowPolygonPoints(edgeData.styleAttributes?.arrow) 175 | if (edge.fromLineEnd?.el) edge.fromLineEnd.el.querySelector('polygon')?.setAttribute('points', arrowPolygonPoints) 176 | if (edge.toLineEnd?.el) edge.toLineEnd.el.querySelector('polygon')?.setAttribute('points', arrowPolygonPoints) 177 | } 178 | 179 | private onEdgeCenterRequested(_canvas: Canvas, edge: CanvasEdge, center: Position) { 180 | center.x = edge.center?.x ?? center.x 181 | center.y = edge.center?.y ?? center.y 182 | } 183 | 184 | private getArrowPolygonPoints(arrowStyle?: string | null): string { 185 | if (arrowStyle === 'halved-triangle') 186 | return `-2,0 7.5,12 -2,12` 187 | else if (arrowStyle === 'thin-triangle') 188 | return `0,0 7,10 0,0 0,10 0,0 -7,10` 189 | else if (arrowStyle === 'diamond' || arrowStyle === 'diamond-outline') 190 | return `0,0 5,10 0,20 -5,10` 191 | else if (arrowStyle === 'circle' || arrowStyle === 'circle-outline') 192 | return `0 0, 4.95 1.8, 7.5 6.45, 6.6 11.7, 2.7 15, -2.7 15, -6.6 11.7, -7.5 6.45, -4.95 1.8` 193 | else if (arrowStyle === 'blunt') 194 | return `-10,8 10,8 10,6 -10,6` 195 | else // Default triangle 196 | return `0,0 6.5,10.4 -6.5,10.4` 197 | } 198 | } -------------------------------------------------------------------------------- /src/canvas-extensions/advanced-styles/node-styles.ts: -------------------------------------------------------------------------------- 1 | import { Canvas } from "src/@types/Canvas" 2 | import CanvasHelper from "src/utils/canvas-helper" 3 | import CanvasExtension from "../canvas-extension" 4 | import { BUILTIN_NODE_STYLE_ATTRIBUTES, StyleAttribute, styleAttributeValidator } from "./style-config" 5 | import CssStylesConfigManager from "src/managers/css-styles-config-manager" 6 | 7 | export default class NodeStylesExtension extends CanvasExtension { 8 | cssStylesManager: CssStylesConfigManager 9 | 10 | isEnabled() { return 'nodeStylingFeatureEnabled' as const } 11 | 12 | init() { 13 | this.cssStylesManager = new CssStylesConfigManager(this.plugin, 'advanced-canvas-node-style', styleAttributeValidator) 14 | 15 | this.plugin.registerEvent(this.plugin.app.workspace.on( 16 | 'advanced-canvas:popup-menu-created', 17 | (canvas: Canvas) => this.onPopupMenuCreated(canvas) 18 | )) 19 | } 20 | 21 | private onPopupMenuCreated(canvas: Canvas): void { 22 | const selectionNodeData = canvas.getSelectionData().nodes 23 | if (canvas.readonly || selectionNodeData.length === 0 || selectionNodeData.length !== canvas.selection.size) 24 | return 25 | 26 | const selectedNodeTypes = new Set(selectionNodeData.map(node => node.type)) 27 | const availableNodeStyles = [...BUILTIN_NODE_STYLE_ATTRIBUTES, /* Legacy */ ...this.plugin.settings.getSetting('customNodeStyleAttributes'), ...this.cssStylesManager.getStyles()] 28 | .filter(style => !style.nodeTypes || style.nodeTypes.some(type => selectedNodeTypes.has(type))) 29 | 30 | CanvasHelper.addStyleAttributesToPopup( 31 | this.plugin, canvas, availableNodeStyles, 32 | selectionNodeData[0].styleAttributes ?? {}, 33 | (attribute, value) => this.setStyleAttributeForSelection(canvas, attribute, value) 34 | ) 35 | } 36 | 37 | private setStyleAttributeForSelection(canvas: Canvas, attribute: StyleAttribute, value: string | null): void { 38 | const selectionNodeData = canvas.getSelectionData().nodes 39 | for (const nodeData of selectionNodeData) { 40 | const node = canvas.nodes.get(nodeData.id) 41 | if (!node) continue 42 | 43 | // Only apply the attribute if the node type is allowed 44 | if (attribute.nodeTypes && !attribute.nodeTypes.includes(nodeData.type)) continue 45 | 46 | node.setData({ 47 | ...nodeData, 48 | styleAttributes: { 49 | ...nodeData.styleAttributes, 50 | [attribute.key]: value 51 | } 52 | }) 53 | } 54 | 55 | canvas.pushHistory(canvas.getData()) 56 | } 57 | } -------------------------------------------------------------------------------- /src/canvas-extensions/advanced-styles/style-config.ts: -------------------------------------------------------------------------------- 1 | import { CanvasNodeType } from "src/@types/AdvancedJsonCanvas" 2 | import TextHelper from "src/utils/text-helper" 3 | 4 | export interface StyleAttributeOption { 5 | icon: string 6 | label: string 7 | value: string | null // The element with the null value is the default 8 | } 9 | 10 | export interface StyleAttribute { 11 | key: string 12 | label: string 13 | nodeTypes?: CanvasNodeType[] 14 | options: StyleAttributeOption[] 15 | } 16 | 17 | export function styleAttributeValidator(json: Record): StyleAttribute | null { 18 | const hasKey = json.key !== undefined 19 | const hasLabel = json.label !== undefined 20 | const hasOptions = Array.isArray(json.options) 21 | 22 | if (!hasKey) console.error('Style attribute is missing the "key" property') 23 | if (!hasLabel) console.error('Style attribute is missing the "label" property') 24 | if (!hasOptions) console.error('Style attribute is missing the "options" property or it is not an array') 25 | 26 | // Camel case the key 27 | json.key = TextHelper.toCamelCase(json.key) 28 | 29 | let optionsValid = true 30 | let hasDefault = false 31 | for (const option of json.options) { 32 | const hasIcon = option.icon !== undefined 33 | const hasLabel = option.label !== undefined 34 | const hasValue = option.value !== undefined 35 | 36 | if (!hasIcon) console.error(`Style attribute option (${option.value ?? option.label}) is missing the "icon" property`) 37 | if (!hasLabel) console.error(`Style attribute option (${option.value}) is missing the "label" property`) 38 | if (!hasValue) console.error(`Style attribute option (${option.label}) is missing the "value" property`) 39 | 40 | if (!hasIcon || !hasLabel || !hasValue) optionsValid = false 41 | if (option.value === null) hasDefault = true 42 | } 43 | if (!hasDefault) console.error('Style attribute is missing a default option (option with a "value" of null)') 44 | 45 | const isValid = hasKey && hasLabel && hasOptions && optionsValid && hasDefault 46 | return isValid ? json as StyleAttribute : null 47 | } 48 | 49 | export const BUILTIN_NODE_STYLE_ATTRIBUTES = [ 50 | { 51 | key: 'textAlign', 52 | label: 'Text Alignment', 53 | nodeTypes: ['text'], 54 | options: [ 55 | { 56 | icon: 'align-left', 57 | label: 'Left', 58 | value: null 59 | }, 60 | { 61 | icon: 'align-center', 62 | label: 'Center', 63 | value: 'center' 64 | }, 65 | { 66 | icon: 'align-right', 67 | label: 'Right', 68 | value: 'right' 69 | } 70 | ] 71 | }, 72 | { 73 | key: 'shape', 74 | label: 'Shape', 75 | nodeTypes: ['text'], 76 | options: [ 77 | { 78 | icon: 'rectangle-horizontal', 79 | label: 'Round Rectangle', 80 | value: null 81 | }, 82 | { 83 | icon: 'shape-pill', 84 | label: 'Pill', 85 | value: 'pill' 86 | }, 87 | { 88 | icon: 'diamond', 89 | label: 'Diamond', 90 | value: 'diamond' 91 | }, 92 | { 93 | icon: 'shape-parallelogram', 94 | label: 'Parallelogram', 95 | value: 'parallelogram' 96 | }, 97 | { 98 | icon: 'circle', 99 | label: 'Circle', 100 | value: 'circle' 101 | }, 102 | { 103 | icon: 'shape-predefined-process', 104 | label: 'Predefined Process', 105 | value: 'predefined-process' 106 | }, 107 | { 108 | icon: 'shape-document', 109 | label: 'Document', 110 | value: 'document' 111 | }, 112 | { 113 | icon: 'shape-database', 114 | label: 'Database', 115 | value: 'database' 116 | } 117 | ] 118 | }, 119 | { 120 | key: 'border', 121 | label: 'Border', 122 | options: [ 123 | { 124 | icon: 'border-solid', 125 | label: 'Solid', 126 | value: null 127 | }, 128 | { 129 | icon: 'border-dashed', 130 | label: 'Dashed', 131 | value: 'dashed' 132 | }, 133 | { 134 | icon: 'border-dotted', 135 | label: 'Dotted', 136 | value: 'dotted' 137 | }, 138 | { 139 | icon: 'eye-off', 140 | label: 'Invisible', 141 | value: 'invisible' 142 | } 143 | ] 144 | } 145 | ] as StyleAttribute[] 146 | 147 | export const BUILTIN_EDGE_STYLE_ATTRIBUTES = [ 148 | { 149 | key: 'path', 150 | label: 'Path Style', 151 | options: [ 152 | { 153 | icon: 'path-solid', 154 | label: 'Solid', 155 | value: null 156 | }, 157 | { 158 | icon: 'path-dotted', 159 | label: 'Dotted', 160 | value: 'dotted' 161 | }, 162 | { 163 | icon: 'path-short-dashed', 164 | label: 'Short Dashed', 165 | value: 'short-dashed' 166 | }, 167 | { 168 | icon: 'path-long-dashed', 169 | label: 'Long Dashed', 170 | value: 'long-dashed' 171 | } 172 | ] 173 | }, 174 | { 175 | key: 'arrow', 176 | label: 'Arrow Style', 177 | options: [ 178 | { 179 | icon: 'arrow-triangle', 180 | label: 'Triangle', 181 | value: null 182 | }, 183 | { 184 | icon: 'arrow-triangle-outline', 185 | label: 'Triangle Outline', 186 | value: 'triangle-outline' 187 | }, 188 | { 189 | icon: 'arrow-thin-triangle', 190 | label: 'Thin Triangle', 191 | value: 'thin-triangle' 192 | }, 193 | { 194 | icon: 'arrow-halved-triangle', 195 | label: 'Halved Triangle', 196 | value: 'halved-triangle' 197 | }, 198 | { 199 | icon: 'arrow-diamond', 200 | label: 'Diamond', 201 | value: 'diamond' 202 | }, 203 | { 204 | icon: 'arrow-diamond-outline', 205 | label: 'Diamond Outline', 206 | value: 'diamond-outline' 207 | }, 208 | { 209 | icon: 'arrow-circle', 210 | label: 'Circle', 211 | value: 'circle' 212 | }, 213 | { 214 | icon: 'arrow-circle-outline', 215 | label: 'Circle Outline', 216 | value: 'circle-outline' 217 | }, 218 | { 219 | icon: 'tally-1', 220 | label: 'Blunt', 221 | value: 'blunt' 222 | } 223 | ] 224 | }, 225 | { 226 | key: 'pathfindingMethod', 227 | label: 'Pathfinding Method', 228 | options: [ 229 | { 230 | icon: 'pathfinding-method-bezier', 231 | label: 'Bezier', 232 | value: null 233 | }, 234 | { 235 | icon: 'slash', 236 | label: 'Direct', 237 | value: 'direct' 238 | }, 239 | { 240 | icon: 'pathfinding-method-square', 241 | label: 'Square', 242 | value: 'square' 243 | }, 244 | { 245 | icon: 'map', 246 | label: 'A*', 247 | value: 'a-star' 248 | } 249 | ] 250 | } 251 | ] as StyleAttribute[] -------------------------------------------------------------------------------- /src/canvas-extensions/auto-file-node-edges-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian" 2 | import CanvasExtension from "./canvas-extension" 3 | import { Canvas, CanvasNode } from "src/@types/Canvas" 4 | import { CanvasEdgeData, CanvasFileNodeData } from "src/@types/AdvancedJsonCanvas" 5 | import BBoxHelper from "src/utils/bbox-helper" 6 | import CanvasHelper from "src/utils/canvas-helper" 7 | 8 | const AUTO_EDGE_ID_PREFIX = "afe" 9 | 10 | export default class AutoFileNodeEdgesCanvasExtension extends CanvasExtension { 11 | isEnabled() { return 'autoFileNodeEdgesFeatureEnabled' as const } 12 | 13 | init() { 14 | this.plugin.registerEvent(this.plugin.app.metadataCache.on('changed', (file: TFile) => { 15 | for (const canvas of this.plugin.getCanvases()) 16 | this.onMetadataChanged(canvas, file) 17 | })) 18 | 19 | this.plugin.registerEvent(this.plugin.app.workspace.on( 20 | 'advanced-canvas:node-added', 21 | (canvas: Canvas, node: CanvasNode) => this.onNodeChanged(canvas, node) 22 | )) 23 | 24 | this.plugin.registerEvent(this.plugin.app.workspace.on( 25 | 'advanced-canvas:node-changed', 26 | (canvas: Canvas, node: CanvasNode) => this.onNodeChanged(canvas, node) 27 | )) 28 | } 29 | 30 | private onMetadataChanged(canvas: Canvas, file: TFile) { 31 | for (const node of canvas.nodes.values()) { 32 | if (node.getData().type !== 'file' || node.file?.path !== file.path) continue 33 | 34 | this.updateFileNodeEdges(canvas, node) 35 | } 36 | } 37 | 38 | private onNodeChanged(canvas: Canvas, node: CanvasNode) { 39 | if (node.getData().type !== 'file') return 40 | 41 | // Update all nodes (a node could've been added) 42 | for (const node of canvas.nodes.values()) { 43 | if (node.getData().type !== 'file') continue 44 | 45 | this.updateFileNodeEdges(canvas, node) 46 | } 47 | } 48 | 49 | private updateFileNodeEdges(canvas: Canvas, node: CanvasNode) { 50 | const edges = this.getFileNodeEdges(canvas, node) 51 | 52 | // Filter out existing edges 53 | const newEdges = Array.from(edges.values()) 54 | .filter(edge => !canvas.edges.has(edge.id)) 55 | 56 | // Add new edges 57 | canvas.importData({ nodes: [], edges: newEdges }, false, false) 58 | 59 | // Remove old edges 60 | for (const edge of canvas.edges.values()) { 61 | if (edge.id.startsWith(`${AUTO_EDGE_ID_PREFIX}${node.id}`) && !edges.has(edge.id)) 62 | canvas.removeEdge(edge) 63 | } 64 | } 65 | 66 | private getFileNodeEdges(canvas: Canvas, node: CanvasNode): Map { 67 | const canvasFile = canvas.view.file 68 | if (!canvasFile || !node.file) return new Map() 69 | 70 | const fileMetadata = this.plugin.app.metadataCache.getFileCache(node.file) 71 | if (!fileMetadata) return new Map() 72 | 73 | const linkedFilesFrontmatterKey = this.plugin.settings.getSetting('autoFileNodeEdgesFrontmatterKey') 74 | const fileLinksToBeLinkedTo = fileMetadata.frontmatterLinks?.filter(link => link.key.split(".")[0] === linkedFilesFrontmatterKey) ?? [] 75 | 76 | const filepathsToBeLinkedTo = fileLinksToBeLinkedTo 77 | .map(link => this.plugin.app.metadataCache.getFirstLinkpathDest(link.link, canvasFile.path)) 78 | .map(file => file?.path) 79 | .filter(path => path !== null) 80 | 81 | const nodesToBeLinkedTo = Array.from(canvas.nodes.values()) 82 | .filter(otherNode => otherNode.id !== node.id && filepathsToBeLinkedTo.includes(otherNode.file?.path)) 83 | 84 | const newEdges: Map = new Map() 85 | for (const otherNode of nodesToBeLinkedTo) { 86 | const edgeId = `${AUTO_EDGE_ID_PREFIX}${node.id}${otherNode.id}` 87 | 88 | const bestFromSide = CanvasHelper.getBestSideForFloatingEdge(BBoxHelper.getCenterOfBBoxSide(otherNode.getBBox(), "right"), node) 89 | const bestToSide = CanvasHelper.getBestSideForFloatingEdge(BBoxHelper.getCenterOfBBoxSide(node.getBBox(), "left"), otherNode) 90 | 91 | newEdges.set(edgeId, { 92 | id: edgeId, 93 | fromNode: node.id, 94 | fromSide: bestFromSide, 95 | fromFloating: true, 96 | toNode: otherNode.id, 97 | toSide: bestToSide, 98 | toFloating: true, 99 | }) 100 | } 101 | 102 | return newEdges 103 | } 104 | } -------------------------------------------------------------------------------- /src/canvas-extensions/auto-resize-node-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { ViewUpdate } from "@codemirror/view" 2 | import { Canvas, CanvasNode } from "src/@types/Canvas" 3 | import CanvasHelper from "src/utils/canvas-helper" 4 | import CanvasExtension from "./canvas-extension" 5 | import { CanvasFileNodeData, CanvasNodeData } from "src/@types/AdvancedJsonCanvas" 6 | 7 | export default class AutoResizeNodeCanvasExtension extends CanvasExtension { 8 | isEnabled() { return 'autoResizeNodeFeatureEnabled' as const } 9 | 10 | init() { 11 | this.plugin.registerEvent(this.plugin.app.workspace.on( 12 | 'advanced-canvas:node-created', 13 | (canvas: Canvas, node: CanvasNode) => this.onNodeCreated(canvas, node) 14 | )) 15 | 16 | this.plugin.registerEvent(this.plugin.app.workspace.on( 17 | 'advanced-canvas:popup-menu-created', 18 | (canvas: Canvas) => this.onPopupMenuCreated(canvas) 19 | )) 20 | 21 | this.plugin.registerEvent(this.plugin.app.workspace.on( 22 | 'advanced-canvas:node-editing-state-changed', 23 | (canvas: Canvas, node: CanvasNode, editing: boolean) => this.onNodeEditingStateChanged(canvas, node, editing) 24 | )) 25 | 26 | this.plugin.registerEvent(this.plugin.app.workspace.on( 27 | 'advanced-canvas:node-text-content-changed', 28 | (canvas: Canvas, node: CanvasNode, viewUpdate: ViewUpdate) => this.onNodeTextContentChanged(canvas, node, viewUpdate.view.dom) 29 | )) 30 | } 31 | 32 | private isValidNodeType(nodeData: CanvasNodeData) { 33 | return nodeData.type === 'text' || (nodeData.type === 'file' && (nodeData as CanvasFileNodeData).file.endsWith('.md')) 34 | } 35 | 36 | private onNodeCreated(_canvas: Canvas, node: CanvasNode) { 37 | const autoResizeNodeEnabledByDefault = this.plugin.settings.getSetting('autoResizeNodeEnabledByDefault') 38 | if (!autoResizeNodeEnabledByDefault) return 39 | 40 | const nodeData = node.getData() 41 | if (nodeData.type !== 'text' && nodeData.type !== 'file') return // File extension can still be changed in the future 42 | 43 | node.setData({ 44 | ...node.getData(), 45 | dynamicHeight: true 46 | }) 47 | } 48 | 49 | private onPopupMenuCreated(canvas: Canvas) { 50 | if (canvas.readonly) return 51 | 52 | const selectedNodes = canvas.getSelectionData().nodes 53 | .filter(nodeData => this.isValidNodeType(nodeData)) 54 | .map(nodeData => canvas.nodes.get(nodeData.id)) 55 | .filter(node => node !== undefined) as CanvasNode[] 56 | if (selectedNodes.length === 0) return 57 | 58 | const autoResizeHeightEnabled = selectedNodes.some(node => node.getData().dynamicHeight) 59 | 60 | CanvasHelper.addPopupMenuOption( 61 | canvas, 62 | CanvasHelper.createPopupMenuOption({ 63 | id: 'auto-resize-height', 64 | label: autoResizeHeightEnabled ? 'Disable auto-resize' : 'Enable auto-resize', 65 | icon: autoResizeHeightEnabled ? 'scan-text' : 'lock', 66 | callback: () => this.toggleAutoResizeHeightEnabled(canvas, selectedNodes, autoResizeHeightEnabled) 67 | }) 68 | ) 69 | } 70 | 71 | private toggleAutoResizeHeightEnabled(canvas: Canvas, nodes: CanvasNode[], autoResizeHeight: boolean) { 72 | nodes.forEach(node => node.setData({ 73 | ...node.getData(), 74 | dynamicHeight: !autoResizeHeight 75 | })) 76 | 77 | this.onPopupMenuCreated(canvas) 78 | } 79 | 80 | private canBeResized(node: CanvasNode) { 81 | const nodeData = node.getData() 82 | return nodeData.dynamicHeight 83 | } 84 | 85 | private async onNodeEditingStateChanged(_canvas: Canvas, node: CanvasNode, editing: boolean) { 86 | if (!this.isValidNodeType(node.getData())) return 87 | if (!this.canBeResized(node)) return 88 | 89 | await sleep(10) 90 | 91 | if (editing) { 92 | this.onNodeTextContentChanged(_canvas, node, node.child.editMode.cm.dom) 93 | return 94 | } 95 | 96 | const renderedMarkdownContainer = node.nodeEl.querySelector(".markdown-preview-view.markdown-rendered") as HTMLElement | null 97 | if (!renderedMarkdownContainer) return 98 | 99 | renderedMarkdownContainer.style.height = "min-content" 100 | let newHeight = renderedMarkdownContainer.clientHeight 101 | renderedMarkdownContainer.style.removeProperty("height") 102 | 103 | this.setNodeHeight(node, newHeight) 104 | } 105 | 106 | private async onNodeTextContentChanged(_canvas: Canvas, node: CanvasNode, dom: HTMLElement) { 107 | if (!this.isValidNodeType(node.getData())) return 108 | if (!this.canBeResized(node)) return 109 | 110 | const cmScroller = dom.querySelector(".cm-scroller") as HTMLElement | null 111 | if (!cmScroller) return 112 | 113 | cmScroller.style.height = "min-content" 114 | const newHeight = cmScroller.scrollHeight 115 | cmScroller.style.removeProperty("height") 116 | 117 | this.setNodeHeight(node, newHeight) 118 | } 119 | 120 | private setNodeHeight(node: CanvasNode, height: number) { 121 | if (height === 0) return 122 | 123 | // Limit the height to the maximum allowed 124 | const maxHeight = this.plugin.settings.getSetting('autoResizeNodeMaxHeight') 125 | if (maxHeight != -1 && height > maxHeight) height = maxHeight 126 | 127 | const nodeData = node.getData() 128 | 129 | height = Math.max(height, node.canvas.config.minContainerDimension) 130 | 131 | if (this.plugin.settings.getSetting('autoResizeNodeSnapToGrid')) 132 | height = Math.ceil(height / CanvasHelper.GRID_SIZE) * CanvasHelper.GRID_SIZE 133 | 134 | node.setData({ 135 | ...nodeData, 136 | height: height 137 | }) 138 | } 139 | } -------------------------------------------------------------------------------- /src/canvas-extensions/better-default-settings-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasEdge, CanvasNode } from "src/@types/Canvas" 2 | import CanvasHelper from "src/utils/canvas-helper" 3 | import { FileSelectModal } from "src/utils/modal-helper" 4 | import CanvasExtension from "./canvas-extension" 5 | import { CanvasFileNodeData } from "src/@types/AdvancedJsonCanvas" 6 | 7 | export default class BetterDefaultSettingsCanvasExtension extends CanvasExtension { 8 | isEnabled() { return true } 9 | 10 | init() { 11 | this.modifyCanvasSettings(this.plugin.getCurrentCanvas()) 12 | 13 | this.plugin.registerEvent(this.plugin.app.workspace.on( 14 | 'advanced-canvas:settings-changed', 15 | () => this.modifyCanvasSettings(this.plugin.getCurrentCanvas()) 16 | )) 17 | 18 | this.plugin.registerEvent(this.plugin.app.workspace.on( 19 | 'advanced-canvas:canvas-changed', 20 | (canvas: Canvas) => this.modifyCanvasSettings(canvas) 21 | )) 22 | 23 | this.plugin.registerEvent(this.plugin.app.workspace.on( 24 | 'advanced-canvas:double-click', 25 | (canvas: Canvas, event: MouseEvent, preventDefault: { value: boolean }) => this.onDoubleClick(canvas, event, preventDefault) 26 | )) 27 | 28 | this.plugin.registerEvent(this.plugin.app.workspace.on( 29 | 'advanced-canvas:node-created', 30 | (canvas: Canvas, node: CanvasNode) => { 31 | this.enforceNodeGridAlignment(canvas, node) 32 | this.applyDefaultNodeStyles(canvas, node) 33 | } 34 | )) 35 | 36 | this.plugin.registerEvent(this.plugin.app.workspace.on( 37 | 'advanced-canvas:edge-created', 38 | (canvas: Canvas, edge: CanvasEdge) => this.applyDefaultEdgeStyles(canvas, edge) 39 | )) 40 | 41 | this.plugin.registerEvent(this.plugin.app.workspace.on( 42 | 'advanced-canvas:node-resized', 43 | (canvas: Canvas, node: CanvasNode) => this.enforceMaxNodeWidth(canvas, node) 44 | )) 45 | } 46 | 47 | private modifyCanvasSettings(canvas: Canvas | null) { 48 | if (!canvas) return 49 | 50 | const defaultTextNodeDimensionsArray = this.plugin.settings.getSetting('defaultTextNodeDimensions') 51 | canvas.config.defaultTextNodeDimensions = { 52 | width: defaultTextNodeDimensionsArray[0], 53 | height: defaultTextNodeDimensionsArray[1] 54 | } 55 | 56 | const defaultFileNodeDimensionsArray = this.plugin.settings.getSetting('defaultFileNodeDimensions') 57 | canvas.config.defaultFileNodeDimensions = { 58 | width: defaultFileNodeDimensionsArray[0], 59 | height: defaultFileNodeDimensionsArray[1] 60 | } 61 | 62 | canvas.config.minContainerDimension = this.plugin.settings.getSetting('minNodeSize') 63 | } 64 | 65 | private async onDoubleClick(canvas: Canvas, event: MouseEvent, preventDefault: { value: boolean }) { 66 | if (event.defaultPrevented || event.target !== canvas.wrapperEl || canvas.isDragging || canvas.readonly) return 67 | preventDefault.value = true 68 | 69 | let pos = canvas.posFromEvt(event) 70 | 71 | switch (this.plugin.settings.getSetting('nodeTypeOnDoubleClick')) { 72 | case 'file': 73 | const file = await new FileSelectModal(this.plugin.app, undefined, true).awaitInput() 74 | canvas.createFileNode({ 75 | pos: pos, 76 | position: 'center', 77 | file: file 78 | }) 79 | 80 | break 81 | default: 82 | canvas.createTextNode({ 83 | pos: pos, 84 | position: 'center' 85 | }) 86 | 87 | break 88 | } 89 | } 90 | 91 | private enforceNodeGridAlignment(_canvas: Canvas, node: CanvasNode) { 92 | if (!this.plugin.settings.getSetting('alignNewNodesToGrid')) return 93 | 94 | const nodeData = node.getData() 95 | node.setData({ 96 | ...nodeData, 97 | x: CanvasHelper.alignToGrid(nodeData.x), 98 | y: CanvasHelper.alignToGrid(nodeData.y) 99 | }) 100 | } 101 | 102 | private applyDefaultNodeStyles(_canvas: Canvas, node: CanvasNode) { 103 | const nodeData = node.getData() 104 | if (nodeData.type !== 'text') return 105 | 106 | node.setData({ 107 | ...nodeData, 108 | styleAttributes: { 109 | ...nodeData.styleAttributes, 110 | ...this.plugin.settings.getSetting('defaultTextNodeStyleAttributes') 111 | } 112 | }) 113 | } 114 | 115 | private async applyDefaultEdgeStyles(canvas: Canvas, edge: CanvasEdge) { 116 | const edgeData = edge.getData() 117 | 118 | edge.setData({ 119 | ...edgeData, 120 | styleAttributes: { 121 | ...edgeData.styleAttributes, 122 | ...this.plugin.settings.getSetting('defaultEdgeStyleAttributes') 123 | } 124 | }) 125 | 126 | // Wait until the connecting class is removed (else, the direction will be reset on mousemove (onConnectionPointerdown)) 127 | if (canvas.canvasEl.hasClass('is-connecting')) { 128 | await new Promise(resolve => { 129 | new MutationObserver(() => { 130 | if (!canvas.canvasEl.hasClass('is-connecting')) resolve() 131 | }).observe(canvas.canvasEl, { attributes: true, attributeFilter: ['class'] }) 132 | }) 133 | } 134 | 135 | const lineDirection = this.plugin.settings.getSetting('defaultEdgeLineDirection') 136 | edge.setData({ 137 | ...edge.getData(), 138 | fromEnd: lineDirection === 'bidirectional' ? 'arrow' : 'none', 139 | toEnd: lineDirection === 'nondirectional' ? 'none' : 'arrow', 140 | }) 141 | } 142 | 143 | private enforceMaxNodeWidth(_canvas: Canvas, node: CanvasNode) { 144 | const maxNodeWidth = this.plugin.settings.getSetting('maxNodeWidth') 145 | if (maxNodeWidth <= 0) return 146 | 147 | const nodeData = node.getData() 148 | if (nodeData.type !== 'text' && nodeData.type !== 'file' || (nodeData as CanvasFileNodeData).portal) return 149 | 150 | if (nodeData.width <= maxNodeWidth) return 151 | 152 | node.setData({ 153 | ...nodeData, 154 | x: node.prevX !== undefined ? node.prevX : nodeData.x, // Reset the position to the previous value 155 | width: maxNodeWidth 156 | }) 157 | } 158 | } -------------------------------------------------------------------------------- /src/canvas-extensions/better-readonly-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas } from "src/@types/Canvas" 2 | import { AdvancedCanvasPluginSettingsValues } from "src/settings" 3 | import CanvasHelper, { MenuOption } from "src/utils/canvas-helper" 4 | import CanvasExtension from "./canvas-extension" 5 | 6 | export default class BetterReadonlyCanvasExtension extends CanvasExtension { 7 | isEnabled() { return 'betterReadonlyEnabled' as const } 8 | 9 | private isMovingToBBox = false 10 | 11 | init() { 12 | /* Popup listener */ 13 | this.plugin.registerEvent(this.plugin.app.workspace.on( 14 | 'advanced-canvas:popup-menu-created', 15 | (canvas: Canvas) => this.updatePopupMenu(canvas) 16 | )) 17 | 18 | this.plugin.registerEvent(this.plugin.app.workspace.on( 19 | 'advanced-canvas:viewport-changed:before', 20 | (canvas: Canvas) => this.onBeforeViewPortChanged(canvas) 21 | )) 22 | 23 | // Allow viewport change when using zoom to bbox 24 | this.plugin.registerEvent(this.plugin.app.workspace.on( 25 | 'advanced-canvas:zoom-to-bbox:before', 26 | () => this.isMovingToBBox = true 27 | )) 28 | 29 | /* Readonly listener */ 30 | this.plugin.registerEvent(this.plugin.app.workspace.on( 31 | 'advanced-canvas:readonly-changed', 32 | (canvas: Canvas, _readonly: boolean) => { 33 | this.updatePopupMenu(canvas) 34 | this.updateLockedZoom(canvas) 35 | this.updateLockedPan(canvas) 36 | } 37 | )) 38 | 39 | /* Add settings */ 40 | this.plugin.registerEvent(this.plugin.app.workspace.on( 41 | 'advanced-canvas:canvas-changed', 42 | (canvas: Canvas) => this.addQuickSettings(canvas) 43 | )) 44 | } 45 | 46 | private onBeforeViewPortChanged(canvas: Canvas) { 47 | // Only allow viewport change once when using zoom to bbox 48 | if (this.isMovingToBBox) { 49 | this.isMovingToBBox = false 50 | 51 | this.updateLockedZoom(canvas) 52 | this.updateLockedPan(canvas) 53 | 54 | return 55 | } 56 | 57 | if (!canvas.readonly) return 58 | 59 | if (this.plugin.settings.getSetting('disableZoom')) { 60 | canvas.zoom = canvas.lockedZoom ?? canvas.zoom 61 | canvas.tZoom = canvas.lockedZoom ?? canvas.tZoom 62 | } 63 | 64 | if (this.plugin.settings.getSetting('disablePan')) { 65 | canvas.x = canvas.lockedX ?? canvas.x 66 | canvas.tx = canvas.lockedX ?? canvas.tx 67 | canvas.y = canvas.lockedY ?? canvas.y 68 | canvas.ty = canvas.lockedY ?? canvas.ty 69 | } 70 | } 71 | 72 | private addQuickSettings(canvas: Canvas) { 73 | const settingsContainer = canvas.quickSettingsButton?.parentElement 74 | if (!settingsContainer) return 75 | 76 | CanvasHelper.addControlMenuButton( 77 | settingsContainer, 78 | this.createToggle({ 79 | id: 'disable-node-popup', 80 | label: 'Disable node popup', 81 | icon: 'arrow-up-right-from-circle', 82 | callback: () => this.updatePopupMenu(canvas) 83 | }, 'disableNodePopup') 84 | ) 85 | 86 | CanvasHelper.addControlMenuButton( 87 | settingsContainer, 88 | this.createToggle({ 89 | id: 'disable-zoom', 90 | label: 'Disable zoom', 91 | icon: 'zoom-in', 92 | callback: () => this.updateLockedZoom(canvas) 93 | }, 'disableZoom') 94 | ) 95 | 96 | CanvasHelper.addControlMenuButton( 97 | settingsContainer, 98 | this.createToggle({ 99 | id: 'disable-pan', 100 | label: 'Disable pan', 101 | icon: 'move', 102 | callback: () => this.updateLockedPan(canvas) 103 | }, 'disablePan') 104 | ) 105 | } 106 | 107 | private createToggle(menuOption: MenuOption, settingKey: keyof AdvancedCanvasPluginSettingsValues): HTMLElement { 108 | const toggle = CanvasHelper.createControlMenuButton({ 109 | ...menuOption, 110 | callback: () => (async () => { 111 | const newValue = !this.plugin.settings.getSetting(settingKey) 112 | await this.plugin.settings.setSetting({ [settingKey]: newValue }) 113 | 114 | toggle.dataset.toggled = this.plugin.settings.getSetting(settingKey).toString() 115 | menuOption.callback?.call(this) 116 | })() 117 | }) 118 | toggle.classList.add('show-while-readonly') 119 | 120 | toggle.dataset.toggled = this.plugin.settings.getSetting(settingKey).toString() 121 | 122 | return toggle 123 | } 124 | 125 | private updatePopupMenu(canvas: Canvas) { 126 | const hidden = canvas.readonly && this.plugin.settings.getSetting('disableNodePopup') 127 | canvas.menu.menuEl.style.visibility = hidden ? 'hidden' : 'visible' 128 | } 129 | 130 | private updateLockedZoom(canvas: Canvas) { 131 | canvas.lockedZoom = canvas.tZoom 132 | } 133 | 134 | private updateLockedPan(canvas: Canvas) { 135 | canvas.lockedX = canvas.tx 136 | canvas.lockedY = canvas.ty 137 | } 138 | } -------------------------------------------------------------------------------- /src/canvas-extensions/canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import AdvancedCanvasPlugin from "src/main" 2 | import { AdvancedCanvasPluginSettingsValues } from "src/settings" 3 | 4 | export default abstract class CanvasExtension { 5 | plugin: AdvancedCanvasPlugin 6 | 7 | abstract isEnabled(): boolean | keyof AdvancedCanvasPluginSettingsValues 8 | abstract init(): void 9 | 10 | constructor(plugin: AdvancedCanvasPlugin) { 11 | this.plugin = plugin 12 | 13 | const isEnabled = this.isEnabled() 14 | 15 | if (!(isEnabled === true || this.plugin.settings.getSetting(isEnabled as any))) return 16 | 17 | this.init() 18 | } 19 | } -------------------------------------------------------------------------------- /src/canvas-extensions/collapsible-groups-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { setIcon } from "obsidian" 2 | import { BBox, Canvas, CanvasNode, SelectionData } from "src/@types/Canvas" 3 | import BBoxHelper from "src/utils/bbox-helper" 4 | import CanvasHelper from "src/utils/canvas-helper" 5 | import CanvasExtension from "./canvas-extension" 6 | import { CanvasData, CanvasGroupNodeData } from "src/@types/AdvancedJsonCanvas" 7 | 8 | export default class CollapsibleGroupsCanvasExtension extends CanvasExtension { 9 | isEnabled() { return 'collapsibleGroupsFeatureEnabled' as const } 10 | 11 | init() { 12 | this.plugin.registerEvent(this.plugin.app.workspace.on( 13 | 'advanced-canvas:node-changed', 14 | (canvas: Canvas, node: CanvasNode) => this.onNodeChanged(canvas, node) 15 | )) 16 | 17 | this.plugin.registerEvent(this.plugin.app.workspace.on( 18 | 'advanced-canvas:node-bbox-requested', 19 | (canvas: Canvas, node: CanvasNode, bbox: BBox) => this.onNodeBBoxRequested(canvas, node, bbox) 20 | )) 21 | 22 | this.plugin.registerEvent(this.plugin.app.workspace.on( 23 | 'advanced-canvas:copy', 24 | (canvas: Canvas, selectionData: SelectionData) => this.onCopy(canvas, selectionData) 25 | )) 26 | 27 | this.plugin.registerEvent(this.plugin.app.workspace.on( 28 | 'advanced-canvas:data-requested', 29 | (_canvas: Canvas, data: CanvasData) => this.expandAllCollapsedNodes(data) 30 | )) 31 | 32 | this.plugin.registerEvent(this.plugin.app.workspace.on( 33 | 'advanced-canvas:data-loaded:before', 34 | (_canvas: Canvas, data: CanvasData, _setData: (data: CanvasData) => void) => this.collapseNodes(data) 35 | )) 36 | } 37 | 38 | private onNodeChanged(canvas: Canvas, groupNode: CanvasNode) { 39 | const groupNodeData = groupNode.getData() as CanvasGroupNodeData 40 | if (groupNodeData.type !== 'group') return 41 | 42 | // Remove the collapse/expand button 43 | groupNode.collapseEl?.remove() 44 | 45 | // Add collapse/expand button next to the label 46 | const collapseEl = document.createElement('span') 47 | collapseEl.className = 'collapse-button' 48 | setIcon(collapseEl, groupNodeData.collapsed ? 'plus-circle' : 'minus-circle') 49 | 50 | collapseEl.onclick = () => { 51 | const groupNodeData = groupNode.getData() as CanvasGroupNodeData 52 | this.setCollapsed(canvas, groupNode, groupNodeData.collapsed ? undefined : true) 53 | canvas.markMoved(groupNode) 54 | } 55 | 56 | groupNode.collapseEl = collapseEl 57 | groupNode.labelEl?.insertAdjacentElement('afterend', collapseEl) 58 | } 59 | 60 | private onCopy(_canvas: Canvas, selectionData: SelectionData) { 61 | for (const collapsedGroupData of selectionData.nodes as CanvasGroupNodeData[]) { 62 | if (collapsedGroupData.type !== 'group' || !collapsedGroupData.collapsed || !collapsedGroupData.collapsedData) continue 63 | 64 | selectionData.nodes.push(...collapsedGroupData.collapsedData.nodes.map(nodeData => ({ 65 | ...nodeData, 66 | // Restore the relative position of the node to the group 67 | x: nodeData.x + collapsedGroupData.x, 68 | y: nodeData.y + collapsedGroupData.y 69 | }))) 70 | selectionData.edges.push(...collapsedGroupData.collapsedData.edges) 71 | } 72 | } 73 | 74 | private setCollapsed(canvas: Canvas, groupNode: CanvasNode, collapsed: boolean | undefined) { 75 | groupNode.setData({ ...groupNode.getData(), collapsed: collapsed }) 76 | canvas.setData(canvas.getData()) 77 | 78 | canvas.history.current-- 79 | canvas.pushHistory(canvas.getData()) 80 | } 81 | 82 | onNodeBBoxRequested(canvas: Canvas, node: CanvasNode, bbox: BBox) { 83 | const nodeData = node.getData() as CanvasGroupNodeData 84 | if (nodeData.type !== 'group' || !nodeData.collapsed) return 85 | 86 | const collapseElBBox = node.collapseEl?.getBoundingClientRect() 87 | if (!collapseElBBox) return 88 | 89 | const labelElBBox = node.labelEl?.getBoundingClientRect() 90 | if (!labelElBBox) return 91 | 92 | const minPos = canvas.posFromClient({ x: collapseElBBox.left, y: collapseElBBox.top }) 93 | const maxPos = canvas.posFromClient({ x: labelElBBox.right, y: collapseElBBox.bottom }) 94 | 95 | bbox.minX = minPos.x 96 | bbox.minY = minPos.y 97 | bbox.maxX = maxPos.x 98 | bbox.maxY = maxPos.y 99 | } 100 | 101 | private expandAllCollapsedNodes(data: CanvasData) { 102 | data.nodes = data.nodes.flatMap((groupNodeData: CanvasGroupNodeData) => { 103 | const collapsedData = groupNodeData.collapsedData 104 | if (collapsedData === undefined) return [groupNodeData] 105 | 106 | delete groupNodeData.collapsedData // Remove the intermediate value 107 | 108 | data.edges.push(...collapsedData.edges) 109 | return [groupNodeData, ...collapsedData.nodes.map(nodeData => ( 110 | { 111 | ...nodeData, 112 | // Restore the relative position of the node to the group 113 | x: nodeData.x + groupNodeData.x, 114 | y: nodeData.y + groupNodeData.y 115 | } 116 | ))] 117 | }) 118 | } 119 | 120 | private collapseNodes(data: CanvasData) { 121 | data.nodes.forEach((groupNodeData: CanvasGroupNodeData) => { 122 | if (!groupNodeData.collapsed) return 123 | 124 | const groupNodeBBox = CanvasHelper.getBBox([groupNodeData]) 125 | const containedNodesData = data.nodes.filter((nodeData) => 126 | nodeData.id !== groupNodeData.id && BBoxHelper.insideBBox(CanvasHelper.getBBox([nodeData]), groupNodeBBox, false) 127 | ) 128 | const containedEdgesData = data.edges.filter(edgeData => { 129 | return containedNodesData.some(nodeData => nodeData.id === edgeData.fromNode) || 130 | containedNodesData.some(nodeData => nodeData.id === edgeData.toNode) 131 | }) 132 | 133 | data.nodes = data.nodes.filter(nodeData => !containedNodesData.includes(nodeData)) 134 | data.edges = data.edges.filter(edgeData => !containedEdgesData.includes(edgeData)) 135 | 136 | groupNodeData.collapsedData = { 137 | nodes: containedNodesData.map(nodeData => ( 138 | { 139 | ...nodeData, 140 | // Store the relative position of the node to the group 141 | x: nodeData.x - groupNodeData.x, 142 | y: nodeData.y - groupNodeData.y 143 | } 144 | )), 145 | edges: containedEdgesData 146 | } 147 | }) 148 | } 149 | } -------------------------------------------------------------------------------- /src/canvas-extensions/color-palette-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceWindow } from "obsidian" 2 | import { Canvas } from "src/@types/Canvas" 3 | import CanvasExtension from "./canvas-extension" 4 | 5 | const DEFAULT_COLORS_COUNT = 6 6 | const CUSTOM_COLORS_MOD_STYLES_ID = 'mod-custom-colors' 7 | 8 | export default class ColorPaletteCanvasExtension extends CanvasExtension { 9 | observer: MutationObserver | null = null 10 | 11 | isEnabled() { return true } 12 | 13 | init() { 14 | this.plugin.registerEvent(this.plugin.app.workspace.on( 15 | 'window-open', 16 | (_win: WorkspaceWindow, _window: Window) => this.updateCustomColorModStyleClasses() 17 | )) 18 | 19 | this.plugin.registerEvent(this.plugin.app.workspace.on( 20 | 'css-change', 21 | () => this.updateCustomColorModStyleClasses() 22 | )) 23 | 24 | this.updateCustomColorModStyleClasses() 25 | 26 | this.plugin.registerEvent(this.plugin.app.workspace.on( 27 | 'advanced-canvas:popup-menu-created', 28 | (canvas: Canvas) => this.patchColorSelection(canvas) 29 | )) 30 | 31 | this.plugin.register(() => this.observer?.disconnect()) 32 | } 33 | 34 | private updateCustomColorModStyleClasses() { 35 | const customCss = this.getCustomColors().map((colorId) => ` 36 | .mod-canvas-color-${colorId} { 37 | --canvas-color: var(--canvas-color-${colorId}); 38 | } 39 | `).join('') 40 | 41 | for (const win of this.plugin.windowsManager.windows) { 42 | const doc = win.document 43 | 44 | doc.getElementById(CUSTOM_COLORS_MOD_STYLES_ID)?.remove() 45 | 46 | const customColorModStyle = doc.createElement('style') 47 | customColorModStyle.id = CUSTOM_COLORS_MOD_STYLES_ID 48 | doc.head.appendChild(customColorModStyle) 49 | 50 | customColorModStyle.textContent = customCss 51 | } 52 | } 53 | 54 | private patchColorSelection(canvas: Canvas) { 55 | if (this.observer) this.observer.disconnect() 56 | 57 | this.observer = new MutationObserver(mutations => { 58 | const colorMenuOpened = mutations.some(mutation => 59 | Object.values(mutation.addedNodes).some((node: Node) => 60 | node instanceof HTMLElement && node.classList.contains('canvas-submenu') && Object.values(node.childNodes).some((node: Node) => 61 | node instanceof HTMLElement && node.classList.contains('canvas-color-picker-item') 62 | ) 63 | ) 64 | ) 65 | if (!colorMenuOpened) return 66 | 67 | const submenu = canvas.menu.menuEl.querySelector('.canvas-submenu') 68 | if (!submenu) return 69 | 70 | const currentNodeColor = canvas.getSelectionData().nodes.map(node => node.color).last() 71 | for (const colorId of this.getCustomColors()) { 72 | const customColorMenuItem = this.createColorMenuItem(canvas, colorId) 73 | if (currentNodeColor === colorId) customColorMenuItem.classList.add('is-active') 74 | 75 | submenu.insertBefore(customColorMenuItem, submenu.lastChild) 76 | } 77 | }) 78 | 79 | this.observer.observe(canvas.menu.menuEl, { childList: true }) 80 | } 81 | 82 | private createColorMenuItem(canvas: Canvas, colorId: string) { 83 | const menuItem = document.createElement('div') 84 | menuItem.classList.add('canvas-color-picker-item') 85 | menuItem.classList.add(`mod-canvas-color-${colorId}`) 86 | 87 | menuItem.addEventListener('click', () => { 88 | menuItem.classList.add('is-active') 89 | 90 | for (const item of canvas.selection) { 91 | item.setColor(colorId) 92 | } 93 | 94 | canvas.requestSave() 95 | }) 96 | 97 | return menuItem 98 | } 99 | 100 | private getCustomColors(): string[] { 101 | const colors: string[] = [] 102 | 103 | while (true) { 104 | const colorId = (DEFAULT_COLORS_COUNT + colors.length + 1).toString() 105 | if (!getComputedStyle(document.body).getPropertyValue(`--canvas-color-${colorId}`)) break 106 | 107 | colors.push(colorId) 108 | } 109 | 110 | return colors 111 | } 112 | } -------------------------------------------------------------------------------- /src/canvas-extensions/dataset-exposers/canvas-metadata-exposer.ts: -------------------------------------------------------------------------------- 1 | import { Canvas } from "src/@types/Canvas" 2 | import CanvasExtension from "../canvas-extension" 3 | 4 | export default class CanvasMetadataExposerExtension extends CanvasExtension { 5 | isEnabled() { return true } 6 | 7 | init() { 8 | this.plugin.registerEvent(this.plugin.app.workspace.on( 9 | 'advanced-canvas:canvas-metadata-changed', 10 | (canvas: Canvas) => this.updateExposedSettings(canvas) 11 | )) 12 | 13 | this.plugin.registerEvent(this.plugin.app.workspace.on( 14 | 'advanced-canvas:canvas-changed', 15 | (canvas: Canvas) => this.updateExposedSettings(canvas) 16 | )) 17 | } 18 | 19 | private updateExposedSettings(canvas: Canvas) { 20 | // Expose start node 21 | const startNodeId = canvas.metadata['startNode'] 22 | for (const [nodeId, node] of canvas.nodes) { 23 | if (nodeId === startNodeId) node.nodeEl.dataset.isStartNode = 'true' 24 | else delete node.nodeEl.dataset.isStartNode 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/canvas-extensions/dataset-exposers/canvas-wrapper-exposer.ts: -------------------------------------------------------------------------------- 1 | import { Canvas } from "src/@types/Canvas" 2 | import { AdvancedCanvasPluginSettingsValues } from "src/settings" 3 | import CanvasExtension from "../canvas-extension" 4 | 5 | const EXPOSED_SETTINGS: (keyof AdvancedCanvasPluginSettingsValues)[] = [ 6 | 'disableFontSizeRelativeToZoom', 7 | 'hideBackgroundGridWhenInReadonly', 8 | 'collapsibleGroupsFeatureEnabled', 9 | 'collapsedGroupPreviewOnDrag', 10 | 'allowFloatingEdgeCreation', 11 | ] 12 | 13 | export default class CanvasWrapperExposerExtension extends CanvasExtension { 14 | isEnabled() { return true } 15 | 16 | init() { 17 | this.plugin.registerEvent(this.plugin.app.workspace.on( 18 | 'advanced-canvas:settings-changed', 19 | () => this.updateExposedSettings(this.plugin.getCurrentCanvas()) 20 | )) 21 | 22 | this.plugin.registerEvent(this.plugin.app.workspace.on( 23 | 'advanced-canvas:canvas-changed', 24 | (canvas: Canvas) => this.updateExposedSettings(canvas) 25 | )) 26 | 27 | this.plugin.registerEvent(this.plugin.app.workspace.on( 28 | 'advanced-canvas:dragging-state-changed', 29 | (canvas: Canvas, dragging: boolean) => { 30 | if (dragging) canvas.wrapperEl.dataset.isDragging = 'true' 31 | else delete canvas.wrapperEl.dataset.isDragging 32 | } 33 | )) 34 | } 35 | 36 | private updateExposedSettings(canvas: Canvas | null) { 37 | if (!canvas) return 38 | 39 | for (const setting of EXPOSED_SETTINGS) { 40 | canvas.wrapperEl.dataset[setting] = this.plugin.settings.getSetting(setting).toString() 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/canvas-extensions/dataset-exposers/edge-exposer.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasEdge } from "src/@types/Canvas" 2 | import SettingsManager from "src/settings" 3 | import CanvasExtension from "../canvas-extension" 4 | import { CanvasEdgeData } from "src/@types/AdvancedJsonCanvas" 5 | 6 | export function getExposedEdgeData(settings: SettingsManager): (keyof CanvasEdgeData)[] { 7 | const exposedData: (keyof CanvasEdgeData)[] = [] 8 | 9 | if (settings.getSetting('edgesStylingFeatureEnabled')) exposedData.push('styleAttributes') 10 | 11 | return exposedData 12 | } 13 | 14 | export default class EdgeExposerExtension extends CanvasExtension { 15 | isEnabled() { return true } 16 | 17 | init() { 18 | this.plugin.registerEvent(this.plugin.app.workspace.on( 19 | 'advanced-canvas:edge-changed', 20 | (_canvas: Canvas, edge: CanvasEdge) => { 21 | const edgeData = edge?.getData() 22 | if (!edgeData) return 23 | 24 | for (const exposedDataKey of getExposedEdgeData(this.plugin.settings)) { 25 | const datasetPairs = edgeData[exposedDataKey] instanceof Object 26 | ? Object.entries(edgeData[exposedDataKey]) 27 | : [[exposedDataKey, edgeData[exposedDataKey]]] 28 | 29 | for (const [key, value] of datasetPairs) { 30 | const stringifiedKey = key?.toString() 31 | if (!stringifiedKey) continue 32 | 33 | if (!value) { 34 | delete edge.path.display.dataset[stringifiedKey] 35 | 36 | if (edge.fromLineEnd?.el) delete edge.fromLineEnd.el.dataset[stringifiedKey] 37 | if (edge.toLineEnd?.el) delete edge.toLineEnd.el.dataset[stringifiedKey] 38 | } else { 39 | edge.path.display.dataset[stringifiedKey] = value.toString() 40 | 41 | if (edge.fromLineEnd?.el) edge.fromLineEnd.el.dataset[stringifiedKey] = value.toString() 42 | if (edge.toLineEnd?.el) edge.toLineEnd.el.dataset[stringifiedKey] = value.toString() 43 | } 44 | } 45 | } 46 | } 47 | )) 48 | } 49 | } -------------------------------------------------------------------------------- /src/canvas-extensions/dataset-exposers/node-exposer.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasNode } from "src/@types/Canvas" 2 | import SettingsManager from "src/settings" 3 | import CanvasExtension from "../canvas-extension" 4 | import { CanvasGroupNodeData, CanvasNodeData } from "src/@types/AdvancedJsonCanvas" 5 | 6 | export function getExposedNodeData(settings: SettingsManager): (keyof CanvasNodeData)[] { 7 | const exposedData: (keyof CanvasNodeData)[] = [] 8 | 9 | if (settings.getSetting('nodeStylingFeatureEnabled')) exposedData.push('styleAttributes') 10 | if (settings.getSetting('collapsibleGroupsFeatureEnabled')) exposedData.push('collapsed' satisfies keyof CanvasGroupNodeData as keyof CanvasNodeData) 11 | if (settings.getSetting('portalsFeatureEnabled')) exposedData.push('isPortalLoaded' as keyof CanvasNodeData) 12 | 13 | return exposedData 14 | } 15 | 16 | export default class NodeExposerExtension extends CanvasExtension { 17 | isEnabled() { return true } 18 | 19 | init() { 20 | this.plugin.registerEvent(this.plugin.app.workspace.on( 21 | 'advanced-canvas:node-changed', 22 | (_canvas: Canvas, node: CanvasNode) => { 23 | const nodeData = node?.getData() 24 | if (!nodeData) return 25 | 26 | for (const exposedDataKey of getExposedNodeData(this.plugin.settings)) { 27 | const datasetPairs = nodeData[exposedDataKey] instanceof Object 28 | ? Object.entries(nodeData[exposedDataKey]) 29 | : [[exposedDataKey, nodeData[exposedDataKey]]] 30 | 31 | for (const [key, value] of datasetPairs as [string, string][]) { 32 | if (!value) delete node.nodeEl.dataset[key] 33 | else node.nodeEl.dataset[key] = value 34 | } 35 | } 36 | } 37 | )) 38 | } 39 | } -------------------------------------------------------------------------------- /src/canvas-extensions/dataset-exposers/node-interaction-exposer.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasNode } from "src/@types/Canvas" 2 | import CanvasExtension from "../canvas-extension" 3 | import { getExposedNodeData } from "./node-exposer" 4 | import PortalsCanvasExtension from "../portals-canvas-extension" 5 | 6 | export const TARGET_NODE_DATASET_PREFIX = "target" 7 | 8 | export default class NodeInteractionExposerExtension extends CanvasExtension { 9 | isEnabled() { return true } 10 | 11 | init() { 12 | this.plugin.registerEvent(this.plugin.app.workspace.on( 13 | 'advanced-canvas:node-interaction', 14 | (canvas: Canvas, node: CanvasNode) => { 15 | const nodeData = node?.getData() 16 | if (!nodeData) return 17 | 18 | const interactionEl = canvas.nodeInteractionLayer.interactionEl 19 | if (!interactionEl) return 20 | 21 | for (const exposedDataKey of getExposedNodeData(this.plugin.settings)) { 22 | const datasetPairs = nodeData[exposedDataKey] instanceof Object 23 | ? Object.entries(nodeData[exposedDataKey]) 24 | : [[exposedDataKey, nodeData[exposedDataKey]]] 25 | 26 | for (const [key, value] of datasetPairs as [string, string][]) { 27 | const modifiedKey = TARGET_NODE_DATASET_PREFIX + key.toString().charAt(0).toUpperCase() + key.toString().slice(1) 28 | 29 | if (!value) delete interactionEl.dataset[modifiedKey] 30 | else interactionEl.dataset[modifiedKey] = value 31 | } 32 | } 33 | 34 | // Custom treatment for portal nodes 35 | if (PortalsCanvasExtension.isPortalElement(node)) interactionEl.dataset.isFromPortal = 'true' 36 | else delete interactionEl.dataset.isFromPortal 37 | } 38 | )) 39 | } 40 | } -------------------------------------------------------------------------------- /src/canvas-extensions/edge-highlight-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasElement } from "src/@types/Canvas" 2 | import CanvasExtension from "./canvas-extension" 3 | 4 | export default class EdgeHighlightCanvasExtension extends CanvasExtension { 5 | isEnabled() { return 'edgeHighlightEnabled' as const } 6 | 7 | init() { 8 | this.plugin.registerEvent(this.plugin.app.workspace.on( 9 | 'advanced-canvas:selection-changed', 10 | (canvas: Canvas, oldSelection: Set, updateSelection: (update: () => void) => void) => this.onSelectionChanged(canvas, oldSelection) 11 | )) 12 | } 13 | 14 | private onSelectionChanged(canvas: Canvas, oldSelection: Set) { 15 | const connectedEdgesToBeHighlighted = new Set(canvas.getSelectionData().nodes 16 | .flatMap(nodeData => [ 17 | ...canvas.edgeFrom.get(canvas.nodes.get(nodeData.id)!) ?? [], 18 | ...(this.plugin.settings.getSetting("highlightIncomingEdges") ? 19 | canvas.edgeTo.get(canvas.nodes.get(nodeData.id)!) ?? [] : 20 | [] 21 | ) 22 | ])) 23 | 24 | for (const edge of canvas.edges.values()) { 25 | edge.lineGroupEl.classList.toggle("is-focused", 26 | canvas.selection.has(edge) || connectedEdgesToBeHighlighted.has(edge) 27 | ) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/canvas-extensions/encapsulate-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from "obsidian" 2 | import { Canvas } from "src/@types/Canvas" 3 | import CanvasHelper from "src/utils/canvas-helper" 4 | import { FileNameModal } from "src/utils/modal-helper" 5 | import CanvasExtension from "./canvas-extension" 6 | 7 | const ENCAPSULATED_FILE_NODE_SIZE = { width: 300, height: 300 } 8 | 9 | export default class EncapsulateCanvasExtension extends CanvasExtension { 10 | isEnabled() { return 'canvasEncapsulationEnabled' as const } 11 | 12 | init() { 13 | /* Add command to encapsulate selection */ 14 | this.plugin.addCommand({ 15 | id: 'encapsulate-selection', 16 | name: 'Encapsulate selection', 17 | checkCallback: CanvasHelper.canvasCommand( 18 | this.plugin, 19 | (canvas: Canvas) => !canvas.readonly && canvas.selection.size > 0, 20 | (canvas: Canvas) => this.encapsulateSelection(canvas) 21 | ) 22 | }) 23 | 24 | /* Add encapsulate option to context menu */ 25 | this.plugin.registerEvent(this.plugin.app.workspace.on( 26 | 'canvas:selection-menu', 27 | (menu: Menu, canvas: Canvas) => { 28 | menu.addItem((item) => 29 | item 30 | .setTitle('Encapsulate') 31 | .setIcon('file-plus') 32 | .onClick(() => this.encapsulateSelection(canvas)) 33 | ) 34 | } 35 | )) 36 | } 37 | 38 | async encapsulateSelection(canvas: Canvas) { 39 | const selection = canvas.getSelectionData() 40 | 41 | // Create new file 42 | const canvasSettings = this.plugin.app.internalPlugins.plugins.canvas.instance.options 43 | 44 | const defaultNewCanvasLocation = canvasSettings.newFileLocation 45 | let targetFolderPath = this.plugin.app.vault.getRoot().path // If setting is "root" 46 | if (defaultNewCanvasLocation === 'current') targetFolderPath = canvas.view.file?.parent?.path ?? targetFolderPath 47 | else if (defaultNewCanvasLocation === 'folder') targetFolderPath = canvasSettings.newFileFolderPath ?? targetFolderPath 48 | 49 | const targetFilePath = await new FileNameModal( 50 | this.plugin.app, 51 | targetFolderPath, 52 | 'canvas' 53 | ).awaitInput() 54 | 55 | const newFileData = { nodes: selection.nodes, edges: selection.edges } 56 | const file = await this.plugin.app.vault.create(targetFilePath, JSON.stringify(newFileData, null, 2)) 57 | 58 | // Remove from current canvas 59 | for (const nodeData of selection.nodes) { 60 | const node = canvas.nodes.get(nodeData.id) 61 | if (node) canvas.removeNode(node) 62 | } 63 | 64 | // Add link to new file in current canvas 65 | canvas.createFileNode({ 66 | pos: { 67 | x: selection.center.x - ENCAPSULATED_FILE_NODE_SIZE.width / 2, 68 | y: selection.center.y - ENCAPSULATED_FILE_NODE_SIZE.height / 2 69 | }, 70 | size: ENCAPSULATED_FILE_NODE_SIZE, 71 | file: file 72 | }) 73 | } 74 | } -------------------------------------------------------------------------------- /src/canvas-extensions/flip-edge-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasEdge } from "src/@types/Canvas" 2 | import CanvasExtension from "./canvas-extension" 3 | import CanvasHelper from "src/utils/canvas-helper" 4 | 5 | export default class FlipEdgeCanvasExtension extends CanvasExtension { 6 | isEnabled() { return 'flipEdgeFeatureEnabled' as const } 7 | 8 | init() { 9 | this.plugin.registerEvent(this.plugin.app.workspace.on( 10 | 'advanced-canvas:popup-menu-created', 11 | (canvas: Canvas) => this.onPopupMenuCreated(canvas) 12 | )) 13 | } 14 | 15 | private onPopupMenuCreated(canvas: Canvas) { 16 | const popupMenuEl = canvas?.menu?.menuEl 17 | if (!popupMenuEl) return 18 | 19 | const POSSIBLE_ICONS = ['lucide-arrow-right', 'lucide-move-horizontal', 'line-horizontal'] 20 | let edgeDirectionButton = null 21 | for (const icon of POSSIBLE_ICONS) { 22 | edgeDirectionButton = popupMenuEl.querySelector(`button:not([id]) > .svg-icon.${icon}`)?.parentElement 23 | if (edgeDirectionButton) break 24 | } 25 | if (!edgeDirectionButton) return 26 | 27 | edgeDirectionButton.addEventListener('click', () => this.onEdgeDirectionDropdownCreated(canvas)) 28 | } 29 | 30 | private onEdgeDirectionDropdownCreated(canvas: Canvas) { 31 | const dropdownEl = document.body.querySelector('div.menu') 32 | if (!dropdownEl) return 33 | 34 | const separatorEl = CanvasHelper.createDropdownSeparatorElement() 35 | dropdownEl.appendChild(separatorEl) 36 | 37 | const flipEdgeButton = CanvasHelper.createDropdownOptionElement({ 38 | icon: 'flip-horizontal-2', 39 | label: 'Flip Edge', 40 | callback: () => this.flipEdge(canvas) 41 | }) 42 | dropdownEl.appendChild(flipEdgeButton) 43 | } 44 | 45 | private flipEdge(canvas: Canvas) { 46 | const selectedEdges = [...canvas.selection].filter((item: any) => item.path !== undefined) as CanvasEdge[] 47 | if (selectedEdges.length === 0) return 48 | 49 | for (const edge of selectedEdges) { 50 | const edgeData = edge.getData() 51 | 52 | edge.setData({ 53 | ...edgeData, 54 | fromNode: edgeData.toNode, 55 | fromSide: edgeData.toSide, 56 | 57 | toNode: edgeData.fromNode, 58 | toSide: edgeData.fromSide 59 | }) 60 | } 61 | 62 | canvas.pushHistory(canvas.getData()) 63 | } 64 | } -------------------------------------------------------------------------------- /src/canvas-extensions/floating-edge-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { BBox, Canvas, CanvasEdge, CanvasNode, Position } from "src/@types/Canvas" 2 | import CanvasExtension from "./canvas-extension" 3 | import BBoxHelper from "src/utils/bbox-helper" 4 | import { CanvasData, Side } from "src/@types/AdvancedJsonCanvas" 5 | import CanvasHelper from "src/utils/canvas-helper" 6 | 7 | export default class FloatingEdgeCanvasExtension extends CanvasExtension { 8 | isEnabled() { return 'floatingEdgeFeatureEnabled' as const } 9 | 10 | private onPointerMove: (e: MouseEvent) => void 11 | 12 | init() { 13 | this.plugin.registerEvent(this.plugin.app.workspace.on( 14 | 'advanced-canvas:data-loaded:after', 15 | (canvas: Canvas, data: CanvasData, setData: (data: CanvasData) => void) => this.onLoadData(canvas, data) 16 | )) 17 | 18 | this.plugin.registerEvent(this.plugin.app.workspace.on( 19 | 'advanced-canvas:node-moved', 20 | (canvas: Canvas, node: CanvasNode) => this.onNodeMoved(canvas, node) 21 | )) 22 | 23 | if (this.plugin.settings.getSetting('allowFloatingEdgeCreation')) { 24 | this.plugin.registerEvent(this.plugin.app.workspace.on( 25 | 'advanced-canvas:edge-connection-dragging:before', 26 | (canvas: Canvas, edge: CanvasEdge, event: PointerEvent, newEdge: boolean, side: 'from' | 'to') => this.onEdgeStartedDragging(canvas, edge, event, newEdge, side) 27 | )) 28 | } 29 | 30 | this.plugin.registerEvent(this.plugin.app.workspace.on( 31 | 'advanced-canvas:edge-connection-dragging:after', 32 | (canvas: Canvas, edge: CanvasEdge, event: PointerEvent, newEdge: boolean, side: 'from' | 'to') => this.onEdgeStoppedDragging(canvas, edge, event, newEdge, side) 33 | )) 34 | } 35 | 36 | private onLoadData(canvas: Canvas, data: CanvasData) { 37 | for (const edgeData of data.edges) { 38 | const edge = canvas.edges.get(edgeData.id) 39 | if (!edge) return console.warn("Imported edge is not yet loaded :(") 40 | 41 | this.updateEdgeConnectionSide(edge) 42 | } 43 | } 44 | 45 | private onNodeMoved(canvas: Canvas, node: CanvasNode) { 46 | const affectedEdges = canvas.getEdgesForNode(node) 47 | 48 | for (const edge of affectedEdges) 49 | this.updateEdgeConnectionSide(edge) 50 | } 51 | 52 | private updateEdgeConnectionSide(edge: CanvasEdge) { 53 | const edgeData = edge.getData() 54 | 55 | if (edgeData.fromFloating) { 56 | const fixedNodeConnectionPoint = BBoxHelper.getCenterOfBBoxSide(edge.to.node.getBBox(), edge.to.side) 57 | const bestSide = CanvasHelper.getBestSideForFloatingEdge(fixedNodeConnectionPoint, edge.from.node) 58 | 59 | if (bestSide !== edge.from.side) { 60 | edge.setData({ 61 | ...edgeData, 62 | fromSide: bestSide 63 | }) 64 | } 65 | } 66 | 67 | if (edgeData.toFloating) { 68 | const fixedNodeConnectionPoint = BBoxHelper.getCenterOfBBoxSide(edge.from.node.getBBox(), edge.from.side) 69 | const bestSide = CanvasHelper.getBestSideForFloatingEdge(fixedNodeConnectionPoint, edge.to.node) 70 | 71 | if (bestSide !== edge.to.side) { 72 | edge.setData({ 73 | ...edgeData, 74 | toSide: bestSide 75 | }) 76 | } 77 | } 78 | } 79 | 80 | private onEdgeStartedDragging(canvas: Canvas, edge: CanvasEdge, _event: PointerEvent, newEdge: boolean, _side: 'from' | 'to') { 81 | if (newEdge && this.plugin.settings.getSetting("newEdgeFromSideFloating")) edge.setData({ 82 | ...edge.getData(), 83 | fromFloating: true // New edges can only get dragged from the "from" side 84 | }) 85 | 86 | let cachedViewportNodes: [CanvasNode, BBox][] | null = null 87 | let hasNaNFloatingEdgeDropZones = false 88 | this.onPointerMove = event => { 89 | if (cachedViewportNodes === null || hasNaNFloatingEdgeDropZones || canvas.viewportChanged) { 90 | hasNaNFloatingEdgeDropZones = false 91 | 92 | cachedViewportNodes = canvas.getViewportNodes() 93 | .map(node => { 94 | const nodeFloatingEdgeDropZone = this.getFloatingEdgeDropZoneForNode(node) 95 | if (isNaN(nodeFloatingEdgeDropZone.minX) || isNaN(nodeFloatingEdgeDropZone.minY) || isNaN(nodeFloatingEdgeDropZone.maxX) || isNaN(nodeFloatingEdgeDropZone.maxY)) 96 | hasNaNFloatingEdgeDropZones = true 97 | 98 | return [node, nodeFloatingEdgeDropZone] as [CanvasNode, BBox] 99 | }) 100 | } 101 | 102 | for (const [node, nodeFloatingEdgeDropZoneClientRect] of cachedViewportNodes) { 103 | const hovering = BBoxHelper.insideBBox({ x: event.clientX, y: event.clientY }, nodeFloatingEdgeDropZoneClientRect, true) 104 | node.nodeEl.classList.toggle('hovering-floating-edge-zone', hovering) // Update hovering state on node 105 | } 106 | } 107 | document.addEventListener('pointermove', this.onPointerMove) // Listen for pointer move events 108 | } 109 | 110 | private onEdgeStoppedDragging(_canvas: Canvas, edge: CanvasEdge, event: PointerEvent, _newEdge: boolean, side: 'from' | 'to') { 111 | document.removeEventListener('pointermove', this.onPointerMove) // Stop listening for pointer move events 112 | 113 | const dropZoneNode = side === 'from' ? edge.from.node : edge.to.node 114 | const floatingEdgeDropZone = this.getFloatingEdgeDropZoneForNode(dropZoneNode) 115 | const wasDroppedInFloatingEdgeDropZone = this.plugin.settings.getSetting('allowFloatingEdgeCreation') ? 116 | BBoxHelper.insideBBox({ x: event.clientX, y: event.clientY }, floatingEdgeDropZone, true) : 117 | false 118 | 119 | const edgeData = edge.getData() 120 | if (side === 'from' && wasDroppedInFloatingEdgeDropZone == edgeData.fromFloating) return 121 | if (side === 'to' && wasDroppedInFloatingEdgeDropZone == edgeData.toFloating) return 122 | 123 | if (side === 'from') edgeData.fromFloating = wasDroppedInFloatingEdgeDropZone 124 | else edgeData.toFloating = wasDroppedInFloatingEdgeDropZone 125 | 126 | edge.setData(edgeData) 127 | 128 | this.updateEdgeConnectionSide(edge) 129 | } 130 | 131 | private getFloatingEdgeDropZoneForNode(node: CanvasNode): BBox { 132 | const nodeElClientBoundingRect = node.nodeEl.getBoundingClientRect() 133 | const nodeFloatingEdgeDropZoneElStyle = window.getComputedStyle(node.nodeEl, ':after') 134 | const nodeFloatingEdgeDropZoneSize = { 135 | width: parseFloat(nodeFloatingEdgeDropZoneElStyle.getPropertyValue('width')), 136 | height: parseFloat(nodeFloatingEdgeDropZoneElStyle.getPropertyValue('height')) 137 | } 138 | 139 | return { 140 | minX: nodeElClientBoundingRect.left + (nodeElClientBoundingRect.width - nodeFloatingEdgeDropZoneSize.width) / 2, 141 | minY: nodeElClientBoundingRect.top + (nodeElClientBoundingRect.height - nodeFloatingEdgeDropZoneSize.height) / 2, 142 | maxX: nodeElClientBoundingRect.right - (nodeElClientBoundingRect.width - nodeFloatingEdgeDropZoneSize.width) / 2, 143 | maxY: nodeElClientBoundingRect.bottom - (nodeElClientBoundingRect.height - nodeFloatingEdgeDropZoneSize.height) / 2 144 | } 145 | } 146 | } -------------------------------------------------------------------------------- /src/canvas-extensions/focus-mode-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas } from "src/@types/Canvas" 2 | import CanvasHelper from "src/utils/canvas-helper" 3 | import CanvasExtension from "./canvas-extension" 4 | 5 | const CONTROL_MENU_FOCUS_TOGGLE_ID = 'focus-mode-toggle' 6 | 7 | export default class FocusModeCanvasExtension extends CanvasExtension { 8 | isEnabled() { return 'focusModeFeatureEnabled' as const } 9 | 10 | init() { 11 | this.plugin.addCommand({ 12 | id: 'toggle-focus-mode', 13 | name: 'Toggle Focus Mode', 14 | checkCallback: CanvasHelper.canvasCommand( 15 | this.plugin, 16 | (_canvas: Canvas) => true, 17 | (canvas: Canvas) => this.toggleFocusMode(canvas) 18 | ) 19 | }) 20 | 21 | this.plugin.registerEvent(this.plugin.app.workspace.on( 22 | 'advanced-canvas:canvas-changed', 23 | (canvas: Canvas) => this.addControlMenuToggle(canvas) 24 | )) 25 | } 26 | 27 | private addControlMenuToggle(canvas: Canvas) { 28 | const settingsContainer = canvas.quickSettingsButton?.parentElement 29 | if (!settingsContainer) return 30 | 31 | const controlMenuFocusToggle = CanvasHelper.createControlMenuButton({ 32 | id: CONTROL_MENU_FOCUS_TOGGLE_ID, 33 | label: 'Focus Mode', 34 | icon: 'focus', 35 | callback: () => this.toggleFocusMode(canvas) 36 | }) 37 | 38 | CanvasHelper.addControlMenuButton(settingsContainer, controlMenuFocusToggle) 39 | } 40 | 41 | private toggleFocusMode(canvas: Canvas) { 42 | const controlMenuFocusToggle = canvas.quickSettingsButton?.parentElement?.querySelector(`#${CONTROL_MENU_FOCUS_TOGGLE_ID}`) as HTMLElement 43 | if (!controlMenuFocusToggle) return 44 | 45 | const newValue = controlMenuFocusToggle.dataset.toggled !== 'true' 46 | 47 | canvas.wrapperEl.dataset.focusModeEnabled = newValue.toString() 48 | controlMenuFocusToggle.dataset.toggled = newValue.toString() 49 | } 50 | } -------------------------------------------------------------------------------- /src/canvas-extensions/frontmatter-control-button-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas } from "src/@types/Canvas" 2 | import CanvasExtension from "./canvas-extension" 3 | import CanvasHelper from "src/utils/canvas-helper" 4 | import { Notice } from "obsidian" 5 | 6 | export default class FrontmatterControlButtonCanvasExtension extends CanvasExtension { 7 | isEnabled() { return 'canvasMetadataCompatibilityEnabled' as const } 8 | 9 | init() { 10 | this.plugin.registerEvent(this.plugin.app.workspace.on( 11 | 'advanced-canvas:canvas-changed', 12 | (canvas: Canvas) => this.addQuickSettings(canvas) 13 | )) 14 | } 15 | 16 | private addQuickSettings(canvas: Canvas) { 17 | if (!canvas) return 18 | 19 | const settingsContainer = canvas.quickSettingsButton?.parentElement 20 | if (!settingsContainer) return 21 | 22 | CanvasHelper.addControlMenuButton( 23 | settingsContainer, 24 | CanvasHelper.createControlMenuButton({ 25 | id: 'properties-button', 26 | icon: 'info', 27 | label: 'Properties', 28 | callback: () => { 29 | const propertiesPlugin = this.plugin.app.internalPlugins.plugins['properties'] 30 | if (!propertiesPlugin?._loaded) { 31 | new Notice(`Core plugin "Properties" was not found or isn't enabled.`) 32 | return 33 | } 34 | 35 | // Get or create the properties view 36 | let propertiesLeaf = this.plugin.app.workspace.getLeavesOfType('file-properties').first() ?? null 37 | if (!propertiesLeaf) { 38 | propertiesLeaf = this.plugin.app.workspace.getRightLeaf(false) 39 | propertiesLeaf?.setViewState({ type: 'file-properties' }) 40 | } 41 | 42 | // Reveal the properties view 43 | if (propertiesLeaf) this.plugin.app.workspace.revealLeaf(propertiesLeaf) 44 | } 45 | }) 46 | ) 47 | } 48 | } -------------------------------------------------------------------------------- /src/canvas-extensions/group-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, Position } from "src/@types/Canvas" 2 | import CanvasHelper from "src/utils/canvas-helper" 3 | import CanvasExtension from "./canvas-extension" 4 | 5 | const GROUP_NODE_SIZE = { width: 300, height: 300 } 6 | 7 | export default class GroupCanvasExtension extends CanvasExtension { 8 | isEnabled() { return true } 9 | 10 | init() { 11 | this.plugin.registerEvent(this.plugin.app.workspace.on( 12 | 'advanced-canvas:canvas-changed', 13 | (canvas: Canvas) => { 14 | CanvasHelper.addCardMenuOption( 15 | canvas, 16 | CanvasHelper.createCardMenuOption( 17 | canvas, 18 | { 19 | id: 'create-group', 20 | label: 'Drag to add group', 21 | icon: 'group' 22 | }, 23 | () => GROUP_NODE_SIZE, 24 | (canvas: Canvas, pos: Position) => { 25 | canvas.createGroupNode({ 26 | pos: pos, 27 | size: GROUP_NODE_SIZE 28 | }) 29 | } 30 | ) 31 | ) 32 | } 33 | )) 34 | } 35 | } -------------------------------------------------------------------------------- /src/canvas-extensions/metadata-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasView } from "src/@types/Canvas" 2 | import CanvasExtension from "./canvas-extension" 3 | import { CURRENT_SPEC_VERSION } from "src/utils/migration-helper" 4 | import { Notice } from "obsidian" 5 | 6 | export default class MetadataCanvasExtension extends CanvasExtension { 7 | isEnabled() { return true } 8 | 9 | private canvasCssclassesCache: Map = new Map() 10 | 11 | init(): void { 12 | this.plugin.registerEvent(this.plugin.app.workspace.on( 13 | 'advanced-canvas:canvas-changed', 14 | (canvas: Canvas) => this.onCanvasChanged(canvas) 15 | )) 16 | 17 | this.plugin.registerEvent(this.plugin.app.workspace.on( 18 | 'advanced-canvas:canvas-metadata-changed', 19 | (canvas: Canvas) => this.onMetadataChanged(canvas) 20 | )) 21 | 22 | this.plugin.registerEvent(this.plugin.app.workspace.on( 23 | 'advanced-canvas:canvas-view-unloaded:before', 24 | (view: CanvasView) => this.onCanvasViewUnloaded(view) 25 | )) 26 | } 27 | 28 | private onCanvasChanged(canvas: Canvas) { 29 | let metadata = canvas.data?.metadata 30 | if (!metadata || metadata.version !== CURRENT_SPEC_VERSION) 31 | return new Notice("Metadata node not found or version mismatch. Should have been migrated (but wasn't).") 32 | 33 | // Add proxy to metadata to listen for changes 34 | const that = this 35 | const validator = { 36 | get(target: any, key: string) { 37 | if (typeof target[key] === 'object' && target[key] !== null) 38 | return new Proxy(target[key], validator) 39 | else return target[key] 40 | }, 41 | set(target: any, key: string, value: any) { 42 | target[key] = value 43 | 44 | that.plugin.app.workspace.trigger('advanced-canvas:canvas-metadata-changed', canvas) 45 | canvas.requestSave() 46 | 47 | return true 48 | } 49 | } 50 | 51 | // Set canvas metadata 52 | canvas.metadata = new Proxy(metadata, validator) 53 | 54 | // Trigger metadata change event 55 | this.plugin.app.workspace.trigger('advanced-canvas:canvas-metadata-changed', canvas) 56 | } 57 | 58 | private onMetadataChanged(canvas: Canvas) { 59 | // Remove old cssclasses 60 | if (this.canvasCssclassesCache.has(canvas.view)) 61 | canvas.wrapperEl.classList.remove(...this.canvasCssclassesCache.get(canvas.view)!) 62 | 63 | // Set new cssclasses 64 | const currentClasses = canvas.metadata?.frontmatter?.cssclasses as string[] ?? [] 65 | this.canvasCssclassesCache.set(canvas.view, currentClasses) 66 | 67 | if (currentClasses.length > 0) canvas.wrapperEl.classList.add(...currentClasses) 68 | } 69 | 70 | private onCanvasViewUnloaded(view: CanvasView) { 71 | this.canvasCssclassesCache.delete(view) // Remove the cssclasses cache for the view 72 | } 73 | } -------------------------------------------------------------------------------- /src/canvas-extensions/node-ratio-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasNode } from 'src/@types/Canvas' 2 | import CanvasExtension from './canvas-extension' 3 | import { Menu } from 'obsidian' 4 | import { AbstractSelectionModal } from 'src/utils/modal-helper' 5 | 6 | export default class NodeRatioCanvasExtension extends CanvasExtension { 7 | isEnabled() { return true } 8 | 9 | init() { 10 | this.plugin.registerEvent(this.plugin.app.workspace.on( 11 | 'canvas:node-menu', 12 | (menu: Menu, node: CanvasNode) => this.onNodeMenu(menu, node) 13 | )) 14 | 15 | this.plugin.registerEvent(this.plugin.app.workspace.on( 16 | 'advanced-canvas:node-resized', 17 | (canvas: Canvas, node: CanvasNode) => this.onNodeResized(canvas, node) 18 | )) 19 | } 20 | 21 | private onNodeMenu(menu: Menu, node: CanvasNode) { 22 | if (!this.plugin.settings.getSetting('aspectRatioControlFeatureEnabled')) return 23 | 24 | menu.addItem((item) => { 25 | item.setTitle('Set Aspect Ratio') 26 | .setIcon('aspect-ratio') 27 | .onClick(async () => { 28 | const NO_RATIO = 'No ratio enforcement' 29 | const newRatioString = await new AbstractSelectionModal(this.plugin.app, 'Enter aspect ratio (width:height)', ['16:9', '4:3', '3:2', '1:1', NO_RATIO]) 30 | .awaitInput() 31 | 32 | const nodeData = node.getData() 33 | 34 | // Remove the ratio if the user selected "No ratio enforcement" 35 | if (newRatioString === NO_RATIO) { 36 | node.setData({ 37 | ...nodeData, 38 | ratio: undefined 39 | }) 40 | 41 | return 42 | } 43 | 44 | // Otherwise, parse the ratio and set it 45 | const [width, height] = newRatioString.split(':').map(Number) 46 | if (width && height) { 47 | node.setData({ 48 | ...nodeData, 49 | ratio: width / height 50 | }) 51 | 52 | node.setData({ 53 | ...node.getData(), 54 | width: nodeData.height * (width / height), 55 | }) 56 | } 57 | }) 58 | }) 59 | } 60 | 61 | private onNodeResized(_canvas: Canvas, node: CanvasNode) { 62 | const nodeData = node.getData() 63 | if (!nodeData.ratio) return 64 | 65 | const nodeBBox = node.getBBox() 66 | const nodeSize = { 67 | width: nodeBBox.maxX - nodeBBox.minX, 68 | height: nodeBBox.maxY - nodeBBox.minY 69 | } 70 | const nodeAspectRatio = nodeSize.width / nodeSize.height 71 | 72 | if (nodeAspectRatio < nodeData.ratio) 73 | nodeSize.width = nodeSize.height * nodeData.ratio 74 | else nodeSize.height = nodeSize.width / nodeData.ratio 75 | 76 | node.setData({ 77 | ...nodeData, 78 | width: nodeSize.width, 79 | height: nodeSize.height 80 | }) 81 | } 82 | } -------------------------------------------------------------------------------- /src/canvas-extensions/variable-breakpoint-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasNode } from "src/@types/Canvas" 2 | import CanvasExtension from "./canvas-extension" 3 | 4 | export const VARIABLE_BREAKPOINT_CSS_VAR = '--variable-breakpoint' 5 | 6 | export default class VariableBreakpointCanvasExtension extends CanvasExtension { 7 | isEnabled() { return 'variableBreakpointFeatureEnabled' as const } 8 | 9 | init() { 10 | /* this.plugin.registerEvent(this.plugin.app.workspace.on( 11 | 'css-change', 12 | () => {} 13 | )) */ // Not supported because of performance 14 | 15 | this.plugin.registerEvent(this.plugin.app.workspace.on( 16 | 'advanced-canvas:node-breakpoint-changed', 17 | (canvas: Canvas, node: CanvasNode, breakpointRef: { value: boolean }) => this.onNodeBreakpointChanged(canvas, node, breakpointRef) 18 | )) 19 | } 20 | 21 | private onNodeBreakpointChanged(canvas: Canvas, node: CanvasNode, breakpointRef: { value: boolean }) { 22 | if (!node.initialized) return // Not initialized 23 | 24 | if (node.breakpoint === undefined) { 25 | const computedStyle = window.getComputedStyle(node.nodeEl) 26 | const variableBreakpointString = computedStyle.getPropertyValue(VARIABLE_BREAKPOINT_CSS_VAR) 27 | 28 | let numberBreakpoint 29 | if (variableBreakpointString.length > 0 && !isNaN(numberBreakpoint = parseFloat(variableBreakpointString))) 30 | node.breakpoint = numberBreakpoint 31 | else node.breakpoint = null 32 | } 33 | 34 | if (node.breakpoint === null) return // No breakpoint 35 | breakpointRef.value = canvas.zoom > node.breakpoint 36 | } 37 | } -------------------------------------------------------------------------------- /src/canvas-extensions/z-ordering-canvas-extension.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from "obsidian" 2 | import { Canvas, CanvasNode } from "src/@types/Canvas" 3 | import BBoxHelper from "src/utils/bbox-helper" 4 | import CanvasExtension from "./canvas-extension" 5 | 6 | export default class ZOrderingCanvasExtension extends CanvasExtension { 7 | isEnabled() { return 'zOrderingControlFeatureEnabled' as const } 8 | 9 | init() { 10 | this.plugin.registerEvent(this.plugin.app.workspace.on( 11 | 'canvas:node-menu', 12 | (menu: Menu, node: CanvasNode) => this.nodeContextMenu(node, menu) 13 | )) 14 | 15 | this.plugin.registerEvent(this.plugin.app.workspace.on( 16 | 'canvas:selection-menu', 17 | (menu: Menu, canvas: Canvas) => this.selectionContextMenu(canvas, menu) 18 | )) 19 | } 20 | 21 | private nodeContextMenu(node: CanvasNode, menu: Menu) { 22 | this.addZOrderingContextMenuItems(node.canvas, [node], menu) 23 | } 24 | 25 | private selectionContextMenu(canvas: Canvas, menu: Menu) { 26 | const selectedNodes = canvas.getSelectionData().nodes 27 | .map(nodeData => canvas.nodes.get(nodeData.id)) 28 | .filter(node => node !== undefined) as CanvasNode[] 29 | 30 | this.addZOrderingContextMenuItems(canvas, selectedNodes, menu) 31 | } 32 | 33 | private addZOrderingContextMenuItems(canvas: Canvas, nodes: CanvasNode[], menu: Menu) { 34 | menu.addSeparator() 35 | 36 | if (this.plugin.settings.getSetting('zOrderingControlShowOneLayerShiftOptions') && nodes.length === 1) { 37 | menu.addItem(item => { 38 | item.setTitle('Move one layer forward') 39 | item.setIcon('arrow-up') 40 | item.onClick(() => this.moveOneLayer(canvas, nodes.first()!, true)) 41 | }) 42 | 43 | menu.addItem(item => { 44 | item.setTitle('Move one layer backward') 45 | item.setIcon('arrow-down') 46 | item.onClick(() => this.moveOneLayer(canvas, nodes.first()!, false)) 47 | }) 48 | } 49 | 50 | menu.addItem(item => { 51 | item.setTitle('Bring to Front') 52 | item.setIcon('bring-to-front') 53 | item.onClick(() => this.moveMaxLayers(canvas, nodes, true)) 54 | }) 55 | 56 | menu.addItem(item => { 57 | item.setTitle('Send to Back') 58 | item.setIcon('send-to-back') 59 | item.onClick(() => this.moveMaxLayers(canvas, nodes, false)) 60 | }) 61 | 62 | if (nodes.some(node => node.getData().zIndex !== undefined)) { 63 | menu.addItem(item => { 64 | item.setTitle('Remove persistent z-index') 65 | item.setIcon('pin-off') 66 | item.onClick(() => this.removePersistentZIndexes(canvas, nodes)) 67 | }) 68 | } 69 | 70 | menu.addSeparator() 71 | } 72 | 73 | private moveOneLayer(canvas: Canvas, selectedNode: CanvasNode, forward: boolean) { 74 | const selectedNodeBBox = selectedNode.getBBox() 75 | const collidingNodes = [...canvas.nodes.values()] 76 | .filter(node => BBoxHelper.isColliding(selectedNodeBBox, node.getBBox())) // Only nodes that collide with the selected node 77 | .filter(node => node !== selectedNode) // Exclude the selected node 78 | 79 | const nearestZIndexNode = collidingNodes 80 | .sort((a, b) => forward ? a.zIndex - b.zIndex : b.zIndex - a.zIndex) // Sort by zIndex 81 | .filter(node => forward ? node.zIndex > selectedNode.zIndex : node.zIndex < selectedNode.zIndex) // Only nodes that are one layer above or below the selected node 82 | .first() 83 | if (nearestZIndexNode === undefined) return // Already at the top or bottom 84 | 85 | const targetZIndex = nearestZIndexNode.zIndex 86 | 87 | // Shift the nearest zIndex node to the selected node's zIndex 88 | this.setNodesZIndex([nearestZIndexNode], selectedNode.zIndex) 89 | 90 | // Shift the selected node to the nearest zIndex node 91 | this.setNodesZIndex([selectedNode], targetZIndex) 92 | } 93 | 94 | private moveMaxLayers(canvas: Canvas, selectedNodes: CanvasNode[], forward: boolean) { 95 | let targetZIndex = forward ? 96 | Math.max(...this.getAllZIndexes(canvas)) + 1 : 97 | Math.min(...this.getAllZIndexes(canvas)) - selectedNodes.length 98 | 99 | this.setNodesZIndex(selectedNodes, targetZIndex) 100 | } 101 | 102 | private removePersistentZIndexes(_canvas: Canvas, nodes: CanvasNode[]) { 103 | for (const node of nodes) node.setZIndex(undefined) 104 | } 105 | 106 | private setNodesZIndex(nodes: CanvasNode[], zIndex: number) { 107 | const sortedNodes = nodes.sort((a, b) => a.zIndex - b.zIndex) 108 | 109 | for (let i = 0; i < sortedNodes.length; i++) { 110 | const node = sortedNodes[i] 111 | const finalZIndex = zIndex + i 112 | 113 | node.setZIndex(finalZIndex) 114 | } 115 | } 116 | 117 | private getAllZIndexes(canvas: Canvas) { 118 | return [...canvas.nodes.values()].map(n => n.zIndex) 119 | } 120 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, Plugin } from 'obsidian' 2 | import { Canvas, CanvasView } from './@types/Canvas' 3 | 4 | // Utils 5 | import IconsHelper from './utils/icons-helper' 6 | import DebugHelper from './utils/debug-helper' 7 | 8 | // Managers 9 | import SettingsManager from './settings' 10 | import WindowsManager from './managers/windows-manager' 11 | 12 | // Patchers 13 | import Patcher from './patchers/patcher' 14 | import CanvasPatcher from './patchers/canvas-patcher' 15 | import LinkSuggestionsPatcher from './patchers/link-suggestions-patcher' 16 | import EmbedPatcher from './patchers/embed-patcher' 17 | import MetadataCachePatcher from './patchers/metadata-cache-patcher' 18 | import BacklinksPatcher from './patchers/backlinks-patcher' 19 | import OutgoingLinksPatcher from './patchers/outgoing-links-patcher' 20 | import PropertiesPatcher from './patchers/properties-patcher' 21 | import SearchPatcher from './patchers/search-patcher' 22 | import SearchCommandPatcher from './patchers/search-command-patcher' 23 | 24 | // Canvas Extensions 25 | import CanvasExtension from './canvas-extensions/canvas-extension' 26 | import MetadataCanvasExtension from './canvas-extensions/metadata-canvas-extension' 27 | import NodeRatioCanvasExtension from './canvas-extensions/node-ratio-canvas-extension' 28 | import VariableBreakpointCanvasExtension from './canvas-extensions/variable-breakpoint-canvas-extension' 29 | import GroupCanvasExtension from './canvas-extensions/group-canvas-extension' 30 | import PresentationCanvasExtension from './canvas-extensions/presentation-canvas-extension' 31 | import ZOrderingCanvasExtension from './canvas-extensions/z-ordering-canvas-extension' 32 | import BetterReadonlyCanvasExtension from './canvas-extensions/better-readonly-canvas-extension' 33 | import EncapsulateCanvasExtension from './canvas-extensions/encapsulate-canvas-extension' 34 | import CommandsCanvasExtension from './canvas-extensions/commands-canvas-extension' 35 | import AutoResizeNodeCanvasExtension from './canvas-extensions/auto-resize-node-canvas-extension' 36 | import PortalsCanvasExtension from './canvas-extensions/portals-canvas-extension' 37 | import FrontmatterControlButtonCanvasExtension from './canvas-extensions/frontmatter-control-button-canvas-extension' 38 | import BetterDefaultSettingsCanvasExtension from './canvas-extensions/better-default-settings-canvas-extension' 39 | import ColorPaletteCanvasExtension from './canvas-extensions/color-palette-canvas-extension' 40 | import CollapsibleGroupsCanvasExtension from './canvas-extensions/collapsible-groups-canvas-extension' 41 | import FocusModeCanvasExtension from './canvas-extensions/focus-mode-canvas-extension' 42 | import AutoFileNodeEdgesCanvasExtension from './canvas-extensions/auto-file-node-edges-canvas-extension' 43 | import FlipEdgeCanvasExtension from './canvas-extensions/flip-edge-canvas-extension' 44 | import ExportCanvasExtension from './canvas-extensions/export-canvas-extension' 45 | import FloatingEdgeCanvasExtension from './canvas-extensions/floating-edge-canvas-extension' 46 | import EdgeHighlightCanvasExtension from './canvas-extensions/edge-highlight-canvas-extension' 47 | 48 | // Advanced Styles 49 | import NodeStylesExtension from './canvas-extensions/advanced-styles/node-styles' 50 | import EdgeStylesExtension from './canvas-extensions/advanced-styles/edge-styles' 51 | 52 | // Dataset Exposers 53 | import CanvasMetadataExposerExtension from './canvas-extensions/dataset-exposers/canvas-metadata-exposer' 54 | import NodeInteractionExposerExtension from './canvas-extensions/dataset-exposers/node-interaction-exposer' 55 | import NodeExposerExtension from './canvas-extensions/dataset-exposers/node-exposer' 56 | import EdgeExposerExtension from './canvas-extensions/dataset-exposers/edge-exposer' 57 | import CanvasWrapperExposerExtension from './canvas-extensions/dataset-exposers/canvas-wrapper-exposer' 58 | 59 | const PATCHERS = [ 60 | CanvasPatcher, 61 | LinkSuggestionsPatcher, 62 | EmbedPatcher, 63 | MetadataCachePatcher, 64 | BacklinksPatcher, 65 | OutgoingLinksPatcher, 66 | PropertiesPatcher, 67 | SearchPatcher, 68 | SearchCommandPatcher, 69 | ] 70 | 71 | const CANVAS_EXTENSIONS: typeof CanvasExtension[] = [ 72 | // Advanced JSON Canvas Extensions 73 | MetadataCanvasExtension, 74 | NodeStylesExtension, 75 | EdgeStylesExtension, 76 | NodeRatioCanvasExtension, 77 | FloatingEdgeCanvasExtension, 78 | AutoResizeNodeCanvasExtension, 79 | CollapsibleGroupsCanvasExtension, 80 | ColorPaletteCanvasExtension, 81 | PresentationCanvasExtension, 82 | PortalsCanvasExtension, 83 | 84 | // UI Extensions (Non-savable data) 85 | CanvasMetadataExposerExtension, 86 | CanvasWrapperExposerExtension, 87 | NodeExposerExtension, 88 | EdgeExposerExtension, 89 | NodeInteractionExposerExtension, 90 | 91 | FrontmatterControlButtonCanvasExtension, 92 | BetterDefaultSettingsCanvasExtension, 93 | CommandsCanvasExtension, 94 | BetterReadonlyCanvasExtension, 95 | GroupCanvasExtension, 96 | VariableBreakpointCanvasExtension, 97 | EdgeHighlightCanvasExtension, 98 | AutoFileNodeEdgesCanvasExtension, 99 | FlipEdgeCanvasExtension, 100 | ZOrderingCanvasExtension, 101 | ExportCanvasExtension, 102 | FocusModeCanvasExtension, 103 | EncapsulateCanvasExtension, 104 | ] 105 | 106 | export default class AdvancedCanvasPlugin extends Plugin { 107 | debugHelper: DebugHelper 108 | 109 | settings: SettingsManager 110 | windowsManager: WindowsManager 111 | 112 | patchers: Patcher[] 113 | canvasExtensions: CanvasExtension[] 114 | 115 | async onload() { 116 | IconsHelper.addIcons() 117 | 118 | this.settings = new SettingsManager(this) 119 | await this.settings.loadSettings() 120 | this.settings.addSettingsTab() 121 | 122 | this.windowsManager = new WindowsManager(this) 123 | 124 | this.patchers = PATCHERS.map((Patcher: any) => { 125 | try { return new Patcher(this) } 126 | catch (e) { 127 | console.error(`Error initializing patcher ${Patcher.name}:`, e) 128 | } 129 | }) 130 | 131 | this.canvasExtensions = CANVAS_EXTENSIONS.map((Extension: any) => { 132 | try { return new Extension(this) } 133 | catch (e) { 134 | console.error(`Error initializing ac-extension ${Extension.name}:`, e) 135 | } 136 | }) 137 | } 138 | 139 | onunload() {} 140 | 141 | getCanvases(): Canvas[] { 142 | return this.app.workspace.getLeavesOfType('canvas') 143 | .map(leaf => (leaf.view as CanvasView)?.canvas) 144 | .filter(canvas => canvas) 145 | } 146 | 147 | getCurrentCanvasView(): CanvasView | null { 148 | const canvasView = this.app.workspace.getActiveViewOfType(ItemView) 149 | if (canvasView?.getViewType() !== 'canvas') return null 150 | return canvasView as CanvasView 151 | } 152 | 153 | getCurrentCanvas(): Canvas | null { 154 | return this.getCurrentCanvasView()?.canvas || null 155 | } 156 | 157 | createFileSnapshot(path: string, content: string) { 158 | const fileRecoveryPlugin = this.app.internalPlugins.plugins['file-recovery']?.instance 159 | if (!fileRecoveryPlugin) return 160 | 161 | fileRecoveryPlugin.forceAdd(path, content) 162 | } 163 | 164 | // this.app.plugins.plugins["advanced-canvas"].enableDebugMode() 165 | enableDebugMode() { 166 | if (this.debugHelper) return 167 | this.debugHelper = new DebugHelper(this) 168 | } 169 | } -------------------------------------------------------------------------------- /src/managers/css-styles-config-manager.ts: -------------------------------------------------------------------------------- 1 | import { parseYaml } from "obsidian" 2 | import AdvancedCanvasPlugin from "src/main" 3 | 4 | export default class CssStylesConfigManager { 5 | private cachedConfig: T[] | null = null 6 | private configRegex 7 | 8 | constructor( 9 | private plugin: AdvancedCanvasPlugin, 10 | trigger: string, 11 | private validate: (json: Record) => T | null 12 | ) { 13 | // Regex to match CSS multi-line comments with the @trigger word at the beginning (same line such as /* @trigger \n ... */) 14 | this.configRegex = new RegExp(`\\/\\*\\s*@${trigger}\\s*\\n([\\s\\S]*?)\\*\\/`, 'g') 15 | 16 | this.plugin.registerEvent(this.plugin.app.workspace.on( 17 | 'css-change', 18 | () => { this.cachedConfig = null } 19 | )) 20 | } 21 | 22 | getStyles(): T[] { 23 | if (this.cachedConfig) return this.cachedConfig 24 | 25 | this.cachedConfig = [] 26 | 27 | // Parse config from CSS 28 | const styleSheets = document.styleSheets 29 | for (let i = 0; i < styleSheets.length; i++) { 30 | const sheet = styleSheets.item(i) 31 | if (!sheet) continue 32 | 33 | const styleSheetConfigs = this.parseStyleConfigsFromCSS(sheet) 34 | for (const config of styleSheetConfigs) { 35 | const validConfig = this.validate(config) 36 | if (!validConfig) continue 37 | 38 | this.cachedConfig.push(validConfig) 39 | } 40 | } 41 | 42 | return this.cachedConfig 43 | } 44 | 45 | private parseStyleConfigsFromCSS(sheet: CSSStyleSheet): Record[] { 46 | const textContent = sheet?.ownerNode?.textContent?.trim() 47 | if (!textContent) return [] 48 | 49 | const configs = [] 50 | 51 | const matches = textContent.matchAll(this.configRegex) 52 | for (const match of matches) { 53 | const yamlString = match[1] 54 | const configYaml = parseYaml(yamlString) 55 | 56 | configs.push(configYaml) 57 | } 58 | 59 | return configs 60 | } 61 | } -------------------------------------------------------------------------------- /src/managers/windows-manager.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceWindow } from "obsidian" 2 | import AdvancedCanvasPlugin from "src/main" 3 | 4 | export default class WindowsManager { 5 | plugin: AdvancedCanvasPlugin 6 | windows: Window[] = [window] 7 | 8 | constructor(plugin: AdvancedCanvasPlugin) { 9 | this.plugin = plugin 10 | 11 | this.plugin.registerEvent(this.plugin.app.workspace.on('window-open', 12 | (_win: WorkspaceWindow, window: Window) => this.windows.push(window) 13 | )) 14 | 15 | this.plugin.registerEvent(this.plugin.app.workspace.on('window-close', 16 | (_win: WorkspaceWindow, window: Window) => this.windows = this.windows.filter((w) => w !== window) 17 | )) 18 | } 19 | } -------------------------------------------------------------------------------- /src/patchers/backlinks-patcher.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedVault, TAbstractFile, TFile, TFolder } from "obsidian" 2 | import Patcher from "./patcher" 3 | import Backlink from "src/@types/BacklinkPlugin" 4 | 5 | export default class BacklinksPatcher extends Patcher { 6 | private isRecomputingBacklinks: boolean = false 7 | 8 | protected async patch() { 9 | if (!this.plugin.settings.getSetting('canvasMetadataCompatibilityEnabled')) return 10 | 11 | const that = this 12 | await Patcher.waitForViewRequest(this.plugin, "backlink", view => { 13 | Patcher.patchPrototype(this.plugin, view.backlink, { 14 | recomputeBacklink: Patcher.OverrideExisting(next => function (file: TFile, ...args: any[]): void { 15 | that.isRecomputingBacklinks = true 16 | const result = next.call(this, file, ...args) 17 | that.isRecomputingBacklinks = false 18 | return result 19 | }) 20 | }) 21 | }) 22 | 23 | Patcher.patchPrototype(this.plugin, this.plugin.app.vault, { 24 | recurseChildrenAC: _next => function (origin: TAbstractFile, traverse: (file: TAbstractFile) => void) { 25 | for (var stack = [origin]; stack.length > 0;) { 26 | var current = stack.pop() 27 | if (current) { 28 | traverse(current) 29 | 30 | // If the current item is a folder, add its children to the stack 31 | if (current instanceof TFolder) stack = stack.concat(current.children) 32 | } 33 | } 34 | }, 35 | getMarkdownFiles: Patcher.OverrideExisting(next => function (...args: any[]): TFile[] { 36 | if (!that.isRecomputingBacklinks) return next.call(this, ...args) 37 | 38 | // If we are recomputing backlinks, we need to include markdown as well as canvas files 39 | var files: TFile[] = [] 40 | var root = this.getRoot() 41 | 42 | this.recurseChildrenAC(root, (child: TAbstractFile) => { 43 | if (child instanceof TFile && (child.extension === "md" || child.extension === "canvas")) { 44 | files.push(child) 45 | } 46 | }) 47 | 48 | return files 49 | }) 50 | }) 51 | } 52 | } -------------------------------------------------------------------------------- /src/patchers/embed-patcher.ts: -------------------------------------------------------------------------------- 1 | import { Component, EmbedContext, TFile } from "obsidian" 2 | import Patcher from "./patcher" 3 | import AdvancedCanvasEmbed from "src/advanced-canvas-embed" 4 | 5 | export default class EmbedPatcher extends Patcher { 6 | async patch() { 7 | if (!this.plugin.settings.getSetting('enableSingleNodeLinks')) return 8 | 9 | Patcher.patch(this.plugin, this.plugin.app.embedRegistry.embedByExtension, { 10 | canvas: next => function (context: EmbedContext, file: TFile, subpath?: string): Component { 11 | // If there is a subpath, return custom embed 12 | if (subpath) return new AdvancedCanvasEmbed(context, file, subpath) 13 | 14 | return next.call(this, context, file, subpath) 15 | }, 16 | }) 17 | } 18 | } -------------------------------------------------------------------------------- /src/patchers/link-suggestions-patcher.ts: -------------------------------------------------------------------------------- 1 | import SuggestManager, { Suggestion } from "src/@types/SuggestManager" 2 | import Patcher from "./patcher" 3 | import { TFile } from "obsidian" 4 | import { ExtendedCachedMetadata } from "src/@types/Obsidian" 5 | 6 | export default class LinkSuggestionsPatcher extends Patcher { 7 | async patch() { 8 | if (!this.plugin.settings.getSetting('enableSingleNodeLinks')) return 9 | 10 | const suggestManager = this.plugin.app.workspace.editorSuggest.suggests 11 | .find(s => s.suggestManager)?.suggestManager 12 | if (!suggestManager) return console.warn("LinkSuggestionsPatcher: No suggest manager found.") 13 | 14 | const that = this 15 | Patcher.patchThisAndPrototype(this.plugin, suggestManager, { 16 | getHeadingSuggestions: Patcher.OverrideExisting(next => async function (context: any, path: string, subpath: string) { 17 | const result = await next.call(this, context, path, subpath) as Suggestion[] 18 | 19 | // Don't add suggestions if the file is not a canvas file 20 | if (!path.endsWith(".canvas")) return result 21 | 22 | const currentFilePath = this.getSourcePath() 23 | 24 | const targetFile = this.app.metadataCache.getFirstLinkpathDest(path, currentFilePath) 25 | if (!targetFile) return result 26 | 27 | // Check if file exits and really is a canvas file 28 | if (!(targetFile instanceof TFile) || targetFile.extension !== "canvas") return result 29 | 30 | const fileCache = this.app.metadataCache.getFileCache(targetFile) 31 | if (!fileCache) return result 32 | 33 | const canvasNodeCaches = (fileCache as ExtendedCachedMetadata).nodes 34 | if (!canvasNodeCaches) return result 35 | 36 | // TODO: Better suggestion filtering and display 37 | for (const [nodeId, nodeCache] of Object.entries(canvasNodeCaches)) { 38 | if (nodeId === subpath) continue // Skip the current node 39 | 40 | const suggestion: Suggestion = { 41 | file: targetFile, 42 | heading: nodeId, 43 | level: 1, 44 | matches: [], 45 | path: path, 46 | subpath: `#${nodeId}`, 47 | score: 0, 48 | type: "heading", 49 | } 50 | 51 | result.push(suggestion) 52 | } 53 | 54 | return result 55 | }) 56 | }) 57 | } 58 | } -------------------------------------------------------------------------------- /src/patchers/outgoing-links-patcher.ts: -------------------------------------------------------------------------------- 1 | import Patcher from "./patcher" 2 | import OutgoingLink from "src/@types/OutgoingLinkPlugin" 3 | 4 | export default class OutgoingLinksPatcher extends Patcher { 5 | protected async patch() { 6 | if (!this.plugin.settings.getSetting('canvasMetadataCompatibilityEnabled')) return 7 | 8 | const that = this 9 | await Patcher.waitForViewRequest(this.plugin, "outgoing-link", view => { 10 | Patcher.patchPrototype(this.plugin, view.outgoingLink, { 11 | recomputeLinks: Patcher.OverrideExisting(next => function (...args: any[]): void { 12 | const isCanvas = this.file?.extension === 'canvas' 13 | 14 | // Trick the app into thinking that the file is a markdown file 15 | if (isCanvas) this.file.extension = 'md' 16 | 17 | const result = next.call(this, ...args) 18 | 19 | // Revert the extension change 20 | if (isCanvas) this.file.extension = 'canvas' 21 | 22 | return result 23 | }), 24 | recomputeUnlinked: Patcher.OverrideExisting(next => function (...args: any[]): void { 25 | const isCanvas = this.file?.extension === 'canvas' 26 | 27 | // Trick the app into thinking that the file is a markdown file 28 | if (isCanvas) this.file.extension = 'md' 29 | 30 | const result = next.call(this, ...args) 31 | 32 | // Revert the extension change 33 | if (isCanvas) this.file.extension = 'canvas' 34 | 35 | return result 36 | }) 37 | }) 38 | }) 39 | } 40 | } -------------------------------------------------------------------------------- /src/patchers/patcher.ts: -------------------------------------------------------------------------------- 1 | import { around } from "monkey-around" 2 | import AdvancedCanvasPlugin from "src/main" 3 | import { Plugin } from "obsidian" 4 | 5 | // Is any 6 | type IsAny = 0 extends 1 & T ? true : false 7 | type NotAny = IsAny extends true ? never : T 8 | 9 | // All keys in T that are functions 10 | type FunctionKeys = { 11 | [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never 12 | }[keyof T] 13 | 14 | // The type of the function at key K in T 15 | type KeyFunction> = 16 | T[K] extends (...args: any[]) => any ? T[K] : never 17 | 18 | // The type of a patch function for key K in T 19 | type KeyFunctionReplacement, R extends ReturnType>> = 20 | (this: T, ...args: Parameters>) => IsAny>> extends false 21 | ? ReturnType> & NotAny 22 | : any 23 | 24 | // The wrapper of a patch function for key K in T 25 | type PatchFunctionWrapper, R extends ReturnType>> = 26 | (next: KeyFunction) => KeyFunctionReplacement 27 | 28 | // The object of patch functions for T 29 | type FunctionPatchObject = { 30 | [K in FunctionKeys]?: PatchFunctionWrapper>> & { __overrideExisting?: boolean } 31 | } 32 | 33 | export default abstract class Patcher { 34 | plugin: AdvancedCanvasPlugin 35 | 36 | constructor(plugin: AdvancedCanvasPlugin) { 37 | this.plugin = plugin 38 | this.patch() 39 | } 40 | 41 | protected abstract patch(): Promise 42 | 43 | protected static async waitForViewRequest(plugin: AdvancedCanvasPlugin, viewType: string, patch: (view: T) => void): Promise { 44 | return new Promise(resolve => { 45 | const uninstaller = around(plugin.app.viewRegistry.viewByType, { 46 | [viewType]: (next: any) => function (...args: any): any { 47 | const view = next.call(this, ...args) 48 | patch(view) 49 | 50 | // Create a new view 51 | const patchedView = next.call(this, ...args) 52 | 53 | uninstaller() 54 | resolve(patchedView) 55 | 56 | return patchedView 57 | } 58 | }) 59 | }) 60 | } 61 | 62 | static OverrideExisting, R extends ReturnType>>( 63 | fn: PatchFunctionWrapper & { __overrideExisting?: boolean } 64 | ) { return Object.assign(fn, { __overrideExisting: true }) } 65 | 66 | static patchThisAndPrototype( 67 | plugin: Plugin, 68 | object: T | undefined, 69 | patches: FunctionPatchObject, 70 | ): T | null { 71 | Patcher.patch(plugin, object, patches) 72 | return Patcher.patchPrototype(plugin, object, patches) 73 | } 74 | 75 | static patchPrototype( 76 | plugin: Plugin, 77 | target: T | undefined, 78 | patches: FunctionPatchObject 79 | ): T | null { 80 | return Patcher.patch(plugin, target, patches, true) 81 | } 82 | 83 | static patch( 84 | plugin: Plugin, 85 | object: T | undefined, 86 | patches: FunctionPatchObject, 87 | prototype: boolean = false 88 | ): T | null { 89 | if (!object) return null 90 | const target = prototype ? object.constructor.prototype : object 91 | 92 | // Validate override requirements 93 | for (const key of Object.keys(patches) as Array>) { 94 | const patch = patches[key] 95 | if (patch?.__overrideExisting) { 96 | if (typeof target[key] !== 'function') 97 | throw new Error(`Method ${String(key)} does not exist on target`) 98 | } 99 | } 100 | 101 | const uninstaller = around(target as any, patches) 102 | plugin.register(uninstaller) 103 | 104 | return object 105 | } 106 | 107 | static tryPatchWorkspacePrototype( 108 | plugin: Plugin, 109 | getTarget: () => T | undefined, 110 | patches: FunctionPatchObject 111 | ): Promise { 112 | return new Promise((resolve) => { 113 | const result = Patcher.patchPrototype(plugin, getTarget(), patches) 114 | if (result) { 115 | resolve(result) 116 | return 117 | } 118 | 119 | const listener = plugin.app.workspace.on('layout-change', () => { 120 | const result = Patcher.patchPrototype(plugin, getTarget(), patches) 121 | 122 | if (result) { 123 | plugin.app.workspace.offref(listener) 124 | resolve(result) 125 | } 126 | }) 127 | 128 | plugin.registerEvent(listener) 129 | }) 130 | } 131 | } -------------------------------------------------------------------------------- /src/patchers/properties-patcher.ts: -------------------------------------------------------------------------------- 1 | import PropertiesView from "src/@types/PropertiesPlugin" 2 | import Patcher from "./patcher" 3 | import { TFile } from "obsidian" 4 | 5 | export default class PropertiesPatcher extends Patcher { 6 | protected async patch() { 7 | if (!this.plugin.settings.getSetting('canvasMetadataCompatibilityEnabled')) return 8 | 9 | const that = this 10 | await Patcher.waitForViewRequest(this.plugin, "file-properties", view => { 11 | Patcher.patchPrototype(this.plugin, view, { 12 | isSupportedFile: Patcher.OverrideExisting(next => function (file?: TFile): boolean { 13 | // Check if the file is a canvas file 14 | if (file?.extension === 'canvas') return true 15 | 16 | // Otherwise, call the original method 17 | return next.call(this, file) 18 | }), 19 | updateFrontmatter: Patcher.OverrideExisting(next => function (file: TFile, content: string): { [key: string]: any } | null { 20 | // Check if the file is a canvas file 21 | if (file?.extension === 'canvas') { 22 | const frontmatter = JSON.parse(content)?.metadata?.frontmatter ?? {} 23 | 24 | this.rawFrontmatter = JSON.stringify(frontmatter, null, 2) 25 | this.frontmatter = frontmatter 26 | 27 | return frontmatter 28 | } 29 | 30 | // Otherwise, call the original method 31 | return next.call(this, file, content) 32 | }), 33 | saveFrontmatter: Patcher.OverrideExisting(next => function (frontmatter: { [key: string]: any }): void { 34 | // Check if the file is a canvas file 35 | if (this.file?.extension === 'canvas') { 36 | if (this.file !== this.modifyingFile) return 37 | 38 | this.app.vault.process(this.file, (data: string) => { 39 | const content = JSON.parse(data) 40 | if (content?.metadata) content.metadata.frontmatter = frontmatter 41 | 42 | return JSON.stringify(content, null, 2) 43 | }) 44 | 45 | return 46 | } 47 | 48 | // Otherwise, call the original method 49 | return next.call(this, frontmatter) 50 | }) 51 | }) 52 | }) 53 | } 54 | } -------------------------------------------------------------------------------- /src/patchers/search-command-patcher.ts: -------------------------------------------------------------------------------- 1 | import { CanvasView } from "src/@types/Canvas" 2 | import Patcher from "./patcher" 3 | import { setIcon } from "obsidian" 4 | import { CanvasGroupNodeData, CanvasTextNodeData } from "src/@types/AdvancedJsonCanvas" 5 | 6 | export default class SearchCommandPatcher extends Patcher { 7 | protected async patch() { 8 | if (!this.plugin.settings.getSetting('nativeFileSearchEnabled')) return 9 | 10 | const that = this 11 | Patcher.patch(this.plugin, this.plugin.app.commands.commands["editor:open-search"], { 12 | checkCallback: Patcher.OverrideExisting(next => function (this: any, checking: boolean) { 13 | // If there is an active md editor, return the original method 14 | if (that.plugin.app.workspace.activeEditor) return next.call(this, checking) 15 | 16 | // If there is no active canvas view, return the original method 17 | const activeCanvasView = that.plugin.getCurrentCanvasView() 18 | if (!activeCanvasView) return next.call(this, checking) 19 | 20 | // Always allow the command to be executed in canvas view 21 | if (checking) return true 22 | 23 | // Show the search view in the active canvas view 24 | if (!activeCanvasView.canvas.searchEl) new CanvasSearchView(activeCanvasView) 25 | 26 | return true 27 | }) 28 | }) 29 | } 30 | } 31 | 32 | interface SearchMatch { 33 | nodeId: string 34 | content: string 35 | matches: number[][] 36 | } 37 | 38 | class CanvasSearchView { 39 | private view: CanvasView 40 | 41 | private containerEl: HTMLDivElement 42 | private searchInput: HTMLInputElement 43 | private searchCount: HTMLDivElement 44 | 45 | private searchMatches: SearchMatch[] = [] 46 | private matchIndex: number = 0 47 | 48 | constructor(view: CanvasView) { 49 | this.view = view 50 | this.createSearchView() 51 | } 52 | 53 | private createSearchView() { 54 | this.containerEl = document.createElement("div") 55 | this.containerEl.className = "document-search-container" 56 | 57 | const documentSearch = document.createElement("div") 58 | documentSearch.className = "document-search" 59 | this.containerEl.appendChild(documentSearch) 60 | 61 | const searchInputContainer = document.createElement("div") 62 | searchInputContainer.className = "search-input-container document-search-input" 63 | documentSearch.appendChild(searchInputContainer) 64 | 65 | this.searchInput = document.createElement("input") 66 | this.searchInput.type = "text" 67 | this.searchInput.placeholder = "Find..." 68 | this.searchInput.addEventListener("keydown", (e: KeyboardEvent) => this.onKeyDown(e)) 69 | this.searchInput.addEventListener("input", () => this.onInput()) 70 | searchInputContainer.appendChild(this.searchInput) 71 | 72 | this.searchCount = document.createElement("div") 73 | this.searchCount.className = "document-search-count" 74 | this.searchCount.style.display = "none" 75 | this.searchCount.textContent = "0 / 0" 76 | searchInputContainer.appendChild(this.searchCount) 77 | 78 | const documentSearchButtons = document.createElement("div") 79 | documentSearchButtons.className = "document-search-buttons" 80 | documentSearch.appendChild(documentSearchButtons) 81 | 82 | const previousButton = document.createElement("button") 83 | previousButton.className = "clickable-icon document-search-button" 84 | previousButton.setAttribute("aria-label", "Previous\nShift + F3") 85 | previousButton.setAttribute("data-tooltip-position", "top") 86 | setIcon(previousButton, "arrow-up") 87 | previousButton.addEventListener("click", () => this.changeMatch(this.matchIndex - 1)) 88 | documentSearchButtons.appendChild(previousButton) 89 | 90 | const nextButton = document.createElement("button") 91 | nextButton.className = "clickable-icon document-search-button" 92 | nextButton.setAttribute("aria-label", "Next\nF3") 93 | nextButton.setAttribute("data-tooltip-position", "top") 94 | setIcon(nextButton, "arrow-down") 95 | nextButton.addEventListener("click", () => this.changeMatch(this.matchIndex + 1)) 96 | documentSearchButtons.appendChild(nextButton) 97 | 98 | const closeButton = document.createElement("button") 99 | closeButton.className = "clickable-icon document-search-close-button" 100 | closeButton.setAttribute("aria-label", "Exit search") 101 | closeButton.setAttribute("data-tooltip-position", "top") 102 | setIcon(closeButton, "x") 103 | closeButton.addEventListener("click", () => this.close()) 104 | documentSearch.appendChild(closeButton) 105 | 106 | this.view.canvas.wrapperEl.appendChild(this.containerEl) 107 | this.view.canvas.searchEl = this.containerEl 108 | 109 | this.searchInput.focus() 110 | } 111 | 112 | private onKeyDown(e: KeyboardEvent) { 113 | // TODO: Fix arrows moving the node and not the cursor 114 | 115 | if (e.key === "Enter" || e.key === "F3") 116 | this.changeMatch(this.matchIndex + (e.shiftKey ? -1 : 1)) 117 | else if (e.key === "Escape") 118 | this.close() 119 | } 120 | 121 | private onInput() { 122 | const hasQuery = this.searchInput.value.length > 0 123 | this.searchCount.style.display = hasQuery ? "block" : "none" 124 | 125 | if (!hasQuery) this.searchMatches = [] 126 | else { 127 | this.searchMatches = Array.from(this.view.canvas.nodes.values()).map(node => { 128 | const nodeData = node.getData() 129 | 130 | let content: string | undefined = undefined 131 | if (nodeData.type === "text") content = (nodeData as CanvasTextNodeData).text 132 | else if (nodeData.type === "group") content = (nodeData as CanvasGroupNodeData).label 133 | else if (nodeData.type === "file") content = node.child.data 134 | 135 | if (!content) return null 136 | 137 | const matches: number[][] = [] 138 | const regex = new RegExp(this.searchInput.value, "gi") 139 | let match: RegExpExecArray | null 140 | while ((match = regex.exec(content)) !== null) { 141 | matches.push([match.index, match.index + match[0].length]) 142 | } 143 | 144 | return { nodeId: node.id, content: content, matches: matches } 145 | }).filter(match => match && match.matches.length > 0) as SearchMatch[] 146 | } 147 | 148 | // Update match index and update the count display 149 | this.changeMatch(0) 150 | } 151 | 152 | private changeMatch(index: number) { 153 | // Bind the index to the range of searchMatches 154 | if (this.searchMatches.length === 0) this.matchIndex = -1 155 | else { 156 | if (index < 0) index += this.searchMatches.length 157 | this.matchIndex = index % this.searchMatches.length 158 | } 159 | 160 | const match = this.searchMatches[this.matchIndex] 161 | if (match) this.goToMatch(match) 162 | 163 | this.searchCount.textContent = `${this.matchIndex + 1} / ${this.searchMatches.length}` 164 | } 165 | 166 | private goToMatch(match: SearchMatch) { 167 | this.view.setEphemeralState({ match: match }) 168 | } 169 | 170 | private close() { 171 | this.containerEl.remove() 172 | this.view.canvas.searchEl = undefined 173 | } 174 | } -------------------------------------------------------------------------------- /src/patchers/search-patcher.ts: -------------------------------------------------------------------------------- 1 | import { around } from "monkey-around" 2 | import Patcher from "./patcher" 3 | import SearchView, { MatchData, SearchQuery } from "src/@types/SearchPlugin" 4 | 5 | export default class SearchPatcher extends Patcher { 6 | protected async patch() { 7 | if (!this.plugin.settings.getSetting('canvasMetadataCompatibilityEnabled')) return 8 | 9 | const that = this 10 | await Patcher.waitForViewRequest(this.plugin, "search", view => { 11 | // Patch the search view until the searchQuery is set or the plugin is unloaded 12 | const uninstaller = around(view, { 13 | startSearch: (next: any) => function (...args: any): any { 14 | const result = next.call(this, ...args) 15 | 16 | // Patch the searchQuery and revert the search view patch 17 | if (this.searchQuery) { 18 | that.patchSearchQuery(this.searchQuery) 19 | uninstaller() 20 | } 21 | 22 | return result 23 | } 24 | }) 25 | 26 | // Uninstall the patcher when the plugin is unloaded 27 | that.plugin.register(uninstaller) 28 | }) 29 | } 30 | 31 | private patchSearchQuery(searchQuery: SearchQuery) { 32 | Patcher.patchThisAndPrototype(this.plugin, searchQuery, { 33 | _match: Patcher.OverrideExisting(next => function (data: MatchData): any { 34 | const isCanvas = data.strings.filepath.endsWith(".canvas") 35 | 36 | if (isCanvas && !data.cache) 37 | data.cache = this.app.metadataCache.getCache(data.strings.filepath) 38 | 39 | return next.call(this, data) 40 | }) 41 | }) 42 | } 43 | } -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @use "styles/settings"; 2 | @use "styles/search-command"; 3 | @use "styles/menu"; 4 | @use "styles/node-styles"; 5 | @use "styles/edge-styles"; 6 | @use "styles/export"; 7 | @use "styles/better-default-settings"; 8 | @use "styles/better-readonly"; 9 | @use "styles/collapsible-groups"; 10 | @use "styles/floating-edge"; 11 | @use "styles/focus-mode"; 12 | @use "styles/presentation"; 13 | @use "styles/portals"; -------------------------------------------------------------------------------- /src/styles/better-default-settings.scss: -------------------------------------------------------------------------------- 1 | .canvas-wrapper[data-disable-font-size-relative-to-zoom='true'] { 2 | --zoom-multiplier: 1 !important; 3 | } -------------------------------------------------------------------------------- /src/styles/better-readonly.scss: -------------------------------------------------------------------------------- 1 | .canvas-wrapper.mod-readonly[data-hide-background-grid-when-in-readonly="true"] .canvas-background { 2 | visibility: hidden; 3 | } -------------------------------------------------------------------------------- /src/styles/collapsible-groups.scss: -------------------------------------------------------------------------------- 1 | .collapse-button { 2 | position: absolute; 3 | left: 0; 4 | top: calc(-1 * var(--size-4-1) * var(--zoom-multiplier)); 5 | 6 | padding: var(--size-4-1) var(--size-4-2); 7 | 8 | transform-origin: bottom left; 9 | transform: translate(0, -100%) scale(var(--zoom-multiplier)); 10 | 11 | border-radius: var(--radius-s); 12 | color: var(--text-muted); 13 | background-color: rgba(var(--canvas-color), 0.1); 14 | 15 | font-size: 1.5em; 16 | line-height: 1; 17 | 18 | pointer-events: initial; 19 | cursor: pointer; 20 | 21 | transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1); 22 | } 23 | 24 | .canvas-wrapper[data-collapsible-groups-feature-enabled="true"] .canvas-node .canvas-group-label { 25 | left: calc(40px * var(--zoom-multiplier)); 26 | } 27 | 28 | .canvas-node[data-collapsed] { 29 | .canvas-node-container { 30 | display: none; 31 | } 32 | 33 | .canvas-group-label { 34 | max-width: initial; 35 | } 36 | } 37 | 38 | .canvas-wrapper[data-collapsed-group-preview-on-drag="true"][data-is-dragging] .canvas-node[data-collapsed] .canvas-node-container { 39 | display: block; 40 | opacity: 0.5; 41 | 42 | border-style: dashed; 43 | 44 | .canvas-node-content { 45 | background-color: transparent; 46 | } 47 | } 48 | 49 | .canvas-node-interaction-layer[data-target-collapsed] { 50 | .canvas-node-resizer { 51 | pointer-events: none; 52 | cursor: inherit; 53 | 54 | .canvas-node-connection-point { 55 | display: none; 56 | pointer-events: none; 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/styles/edge-styles.scss: -------------------------------------------------------------------------------- 1 | //#region Path 2 | .canvas-edges path { 3 | &[data-path='dotted'] { 4 | stroke-dasharray: calc(3px * var(--zoom-multiplier)); 5 | } 6 | 7 | &[data-path='short-dashed'] { 8 | stroke-dasharray: 9px; 9 | } 10 | 11 | &[data-path='long-dashed'] { 12 | stroke-dasharray: 18px; 13 | } 14 | } 15 | //#endregion 16 | 17 | //#region Arrow 18 | .canvas-edges { 19 | [data-arrow='triangle-outline'], [data-arrow='diamond-outline'], [data-arrow='circle-outline'] { 20 | polygon { 21 | fill: var(--canvas-background); 22 | 23 | stroke: rgb(var(--canvas-color)); 24 | stroke-width: calc(3px * var(--zoom-multiplier)); 25 | } 26 | } 27 | 28 | [data-arrow='thin-triangle'] polygon { 29 | fill: transparent; 30 | 31 | stroke: rgb(var(--canvas-color)); 32 | stroke-width: calc(4px * var(--zoom-multiplier)); 33 | } 34 | } 35 | //#endregion -------------------------------------------------------------------------------- /src/styles/export.scss: -------------------------------------------------------------------------------- 1 | .canvas.is-exporting { 2 | --zoom-multiplier: 1; 3 | 4 | * { 5 | pointer-events: none !important; 6 | transition: none !important; 7 | } 8 | 9 | // Hide group collapse button 10 | .collapse-button { 11 | display: none; 12 | } 13 | 14 | #watermark-ac { 15 | z-index: 9999999; 16 | position: absolute; 17 | } 18 | } 19 | 20 | // Hide group collapse button 21 | .canvas-wrapper[data-collapsible-groups-feature-enabled="true"] .canvas.is-exporting .canvas-node .canvas-group-label { 22 | left: 0; 23 | } 24 | 25 | .progress-bar-modal-ac { 26 | margin-top: .75em; 27 | 28 | &.error .setting-progress-bar { 29 | color: var(--color-error); 30 | } 31 | } -------------------------------------------------------------------------------- /src/styles/floating-edge.scss: -------------------------------------------------------------------------------- 1 | .canvas-wrapper[data-allow-floating-edge-creation="true"] .canvas.is-connecting { 2 | .canvas-node:not(.canvas-node-group) { 3 | &::after { 4 | all: unset; 5 | content: ""; 6 | 7 | $margin: 50px; 8 | $size: max(10px, calc(100% - $margin * var(--zoom-multiplier) * 2)); 9 | 10 | z-index: 100; 11 | 12 | position: absolute; 13 | top: 50%; 14 | left: 50%; 15 | width: $size; 16 | height: $size; 17 | transform: translate(-50%, -50%); 18 | 19 | border-radius: var(--radius-m); 20 | outline: calc(4px * var(--zoom-multiplier)) dashed hsla(var(--color-accent-hsl), 0.5); 21 | } 22 | 23 | &.hovering-floating-edge-zone::after { 24 | outline-color: var(--color-accent); 25 | outline-style: solid; 26 | background-color: hsla(var(--color-accent-hsl), 0.1); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/styles/focus-mode.scss: -------------------------------------------------------------------------------- 1 | .canvas-wrapper[data-focus-mode-enabled="true"] .canvas:has(.canvas-node.is-focused) { 2 | .canvas-node:not(.is-focused) { 3 | filter: blur(5px); 4 | } 5 | 6 | .canvas-edges { 7 | filter: blur(5px); 8 | } 9 | 10 | .canvas-path-label-wrapper { 11 | filter: blur(5px); 12 | } 13 | } -------------------------------------------------------------------------------- /src/styles/menu.scss: -------------------------------------------------------------------------------- 1 | /* Control Menu */ 2 | .canvas-wrapper:not(.mod-readonly) .show-while-readonly { 3 | display: none; 4 | } 5 | 6 | .canvas-control-item[data-toggled="true"] { 7 | background-color: var(--color-accent); 8 | 9 | svg { 10 | stroke: var(--text-on-accent); 11 | } 12 | } -------------------------------------------------------------------------------- /src/styles/node-styles.scss: -------------------------------------------------------------------------------- 1 | .reactive-node { 2 | --border-color: rgb(var(--canvas-color)); 3 | --border-width: 3px; 4 | --box-shadow: none; 5 | 6 | /* Focused */ 7 | &.is-focused, &.is-selected { 8 | --border-color: var(--color-accent); 9 | --border-width: 5px; 10 | --box-shadow: var(--shadow-border-accent); 11 | } 12 | 13 | /* Themed */ 14 | &.is-themed { 15 | --border-color: rgba(var(--canvas-color), 0.7); 16 | } 17 | 18 | &.is-themed { 19 | &.is-focused, &.is-selected { 20 | --border-color: rgb(var(--canvas-color)); 21 | --box-shadow: var(--shadow-border-themed); 22 | } 23 | } 24 | } 25 | 26 | .canvas-node { 27 | //#region Text Alignment 28 | &[data-text-align='center'] { 29 | .markdown-preview-view { 30 | padding: 0 !important; 31 | overflow-y: initial; 32 | 33 | .markdown-preview-section { 34 | display: flex; 35 | flex-direction: column; 36 | justify-content: center; 37 | 38 | min-height: 0 !important; 39 | 40 | text-align: center; 41 | vertical-align: middle; 42 | } 43 | } 44 | } 45 | 46 | &[data-text-align='right'] { 47 | text-align: right; 48 | } 49 | //#endregion 50 | 51 | //#region Shape 52 | @mixin masked($mask) { 53 | mask-image: $mask; 54 | -webkit-mask-image: $mask; 55 | mask-repeat: no-repeat; 56 | -webkit-mask-repeat: no-repeat; 57 | mask-size: 100%; 58 | -webkit-mask-size: 100%; 59 | } 60 | 61 | &[data-shape='pill'] .canvas-node-container { 62 | border-radius: 5000px; 63 | } 64 | 65 | &[data-shape='diamond'] { 66 | @extend .reactive-node; 67 | 68 | // https://yoksel.github.io/url-encoder/ - assets/diamond-shape.svg 69 | $mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 141.42135624 141.42135624' preserveAspectRatio='none'%3E%3Cstyle%3E rect %7B transform-origin: center; transform: rotate(45deg) scale(1.05); %7D %3C/style%3E%3Crect rx='8' x='20.71067812' y='20.71067812' width='100' height='100' /%3E%3C/svg%3E"); 70 | 71 | &.is-focused, &.is-selected { 72 | border-radius: var(--radius-m); 73 | outline: 2px solid var(--color-accent); 74 | outline-offset: 5px; 75 | } 76 | 77 | .canvas-node-container { 78 | border: none; 79 | box-shadow: none !important; 80 | 81 | /* Clip while not editing */ 82 | &:not(:has(.embed-iframe)) { 83 | @include masked($mask); 84 | } 85 | 86 | /* Clip placeholder */ 87 | .canvas-node-placeholder::after { 88 | @include masked($mask); 89 | } 90 | } 91 | 92 | /* Border */ 93 | &::before { 94 | @include masked($mask); 95 | 96 | content: ''; 97 | position: absolute; 98 | top: calc(var(--border-width) * -1); 99 | left: calc(var(--border-width) * -1); 100 | width: calc(100% + var(--border-width) * 2); 101 | height: calc(100% + var(--border-width) * 2); 102 | 103 | background-color: var(--border-color); 104 | } 105 | } 106 | 107 | &[data-shape='parallelogram'] .canvas-node-container { 108 | transform: skewX(-20deg); 109 | 110 | /* Fix blurry text */ 111 | backface-visibility: hidden; 112 | 113 | .canvas-node-content .markdown-embed-content { 114 | transform: skewX(20deg); 115 | } 116 | } 117 | 118 | &[data-shape='circle'] .canvas-node-container { 119 | border-radius: 50%; 120 | 121 | .markdown-preview-view { 122 | padding: 0 !important; 123 | overflow-y: initial; 124 | } 125 | } 126 | 127 | &[data-shape='predefined-process'] { 128 | @extend .reactive-node; 129 | 130 | $line-padding: 10px; 131 | 132 | .canvas-node-container { 133 | .canvas-node-content { 134 | padding: 0 $line-padding; 135 | } 136 | 137 | &::before, &::after { 138 | content: ''; 139 | z-index: 1; 140 | 141 | position: absolute; 142 | top: 0; 143 | 144 | width: 0; 145 | height: 100%; 146 | 147 | border-left: var(--border-width) solid var(--border-color); 148 | } 149 | 150 | &::before { 151 | left: calc($line-padding - var(--border-width)); 152 | } 153 | 154 | &::after { 155 | right: calc($line-padding - var(--border-width)); 156 | } 157 | } 158 | } 159 | 160 | &[data-shape='document'] { 161 | @extend .reactive-node; 162 | 163 | // https://yoksel.github.io/url-encoder/ - assets/document-shape.svg 164 | $mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 75 45' preserveAspectRatio='none'%3E%3Cpath d='M75 0 75 39.375Q56.25 29.25 37.5 39.375 18.75 49.5 0 39.375L0 0Z' /%3E%3C/svg%3E"); 165 | 166 | --border-width: 2.5px; 167 | filter: drop-shadow(0 var(--border-width) 0 var(--border-color)) 168 | drop-shadow(0 calc(var(--border-width) * -1) 0 var(--border-color)); 169 | 170 | .canvas-node-container { 171 | @include masked($mask); 172 | border: var(--border-width) solid var(--border-color); 173 | border-top: none; 174 | border-bottom: none; 175 | } 176 | 177 | /* Focused */ 178 | &.is-focused, &.is-selected { 179 | --border-width: 4px; 180 | } 181 | } 182 | 183 | &[data-shape='database'] { 184 | @extend .reactive-node; 185 | $oval-height: 50px; 186 | 187 | .canvas-node-container { 188 | border: var(--border-width) solid var(--border-color); 189 | border-bottom: 0; 190 | border-top: 0; 191 | 192 | border-radius: 0; 193 | box-shadow: none !important; 194 | 195 | /* Clip placeholder */ 196 | .canvas-node-placeholder { 197 | transform: translateY(calc($oval-height / 2)); 198 | } 199 | } 200 | 201 | &::before, &::after { 202 | content: ''; 203 | position: absolute; 204 | left: 0; 205 | 206 | box-sizing: border-box; 207 | width: 100%; 208 | height: $oval-height; 209 | 210 | border-radius: 50%; 211 | border: var(--border-width) solid var(--border-color); 212 | 213 | background-color: var(--background-primary); 214 | } 215 | 216 | /* Top of cylinder */ 217 | &::after { 218 | top: calc(-1 * $oval-height / 2); 219 | } 220 | 221 | /* Bottom of cylinder */ 222 | &::before { 223 | bottom: calc(-1 * $oval-height / 2); 224 | } 225 | 226 | /* Disable default theme fill */ 227 | &.is-themed .canvas-node-content { 228 | background-color: transparent; 229 | } 230 | 231 | /* Add theme fill if not editing */ 232 | &.is-themed:not(:has(.embed-iframe)) { 233 | .canvas-node-container, &::after, &::before { 234 | box-shadow: inset 0 0 0 1000px rgba(var(--canvas-color), 0.07) !important; 235 | } 236 | } 237 | 238 | /* Offset text if not editing */ 239 | .canvas-node-content:not(:has(.embed-iframe)) { 240 | transform: translateY(calc($oval-height / 2.5)); 241 | } 242 | 243 | /* Move top of cylinder to background while editing */ 244 | &:has(.embed-iframe)::after { 245 | z-index: -1; 246 | } 247 | } 248 | //#endregion 249 | 250 | //#region Borders 251 | &[data-border='dashed'] .canvas-node-container { 252 | box-shadow: none; 253 | border-style: dashed; 254 | } 255 | 256 | &[data-border='dotted'] .canvas-node-container { 257 | box-shadow: none; 258 | border-style: dotted; 259 | } 260 | 261 | &[data-border='invisible'] { 262 | box-shadow: none; 263 | 264 | &:not(.is-focused):not(.is-selected) .canvas-node-container { 265 | border-color: transparent !important; 266 | } 267 | 268 | .canvas-node-label { 269 | display: none; 270 | } 271 | 272 | .canvas-node-container { 273 | background-color: transparent; 274 | box-shadow: none; 275 | } 276 | } 277 | //#endregion 278 | 279 | //#region Combinations 280 | &[data-border][data-shape='predefined-process'] { 281 | --border-width: 2px; 282 | 283 | .is-focused, .is-selected { 284 | --border-width: 2px; 285 | } 286 | } 287 | 288 | &[data-border='dashed'][data-shape='predefined-process'] .canvas-node-container { 289 | &::before, &::after { 290 | border-left: var(--border-width) dashed var(--border-color); 291 | } 292 | } 293 | 294 | &[data-border='dotted'][data-shape='predefined-process'] .canvas-node-container { 295 | &::before, &::after { 296 | border-left: var(--border-width) dotted var(--border-color); 297 | } 298 | } 299 | 300 | &[data-border][data-shape='document'] .canvas-node-container { 301 | border-top: none; 302 | border-bottom: none; 303 | } 304 | //#endregion 305 | } -------------------------------------------------------------------------------- /src/styles/portals.scss: -------------------------------------------------------------------------------- 1 | .canvas-node[data-is-portal-loaded="true"] { 2 | pointer-events: all; 3 | 4 | &:not(.is-focused) { 5 | pointer-events: none; 6 | 7 | .canvas-node-label { 8 | pointer-events: all; 9 | } 10 | } 11 | 12 | .canvas-node-container { 13 | background-color: transparent; 14 | border-style: dashed; 15 | 16 | .canvas-node-content { 17 | display: none; 18 | } 19 | } 20 | } 21 | 22 | .canvas-node-interaction-layer[data-is-from-portal="true"] { 23 | .canvas-node-resizer { 24 | pointer-events: none; 25 | cursor: inherit; 26 | 27 | .canvas-node-connection-point { 28 | pointer-events: all; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/styles/presentation.scss: -------------------------------------------------------------------------------- 1 | .canvas-wrapper.presentation-mode { 2 | .canvas-controls { 3 | visibility: hidden; 4 | } 5 | 6 | .canvas-card-menu { 7 | visibility: hidden; 8 | } 9 | } 10 | 11 | .canvas-wrapper:not(.presentation-mode) .canvas-node[data-is-start-node=true]::before { 12 | content: 'Start'; 13 | 14 | position: absolute; 15 | top: calc(-1 * var(--size-4-1) * var(--zoom-multiplier)); 16 | right: 0; 17 | 18 | transform: translate(0, -100%) scale(var(--zoom-multiplier)); 19 | transform-origin: bottom right; 20 | 21 | max-width: calc(100% / var(--zoom-multiplier)); 22 | padding: var(--size-4-1) var(--size-4-2); 23 | 24 | font-size: 1em; 25 | 26 | border-radius: var(--radius-s); 27 | color: var(--color-green); 28 | background-color: rgba(var(--color-green-rgb), 0.1); 29 | } -------------------------------------------------------------------------------- /src/styles/search-command.scss: -------------------------------------------------------------------------------- 1 | .canvas-wrapper > .document-search-container { 2 | transform: translateZ(0); 3 | margin: 0; 4 | } -------------------------------------------------------------------------------- /src/styles/settings.scss: -------------------------------------------------------------------------------- 1 | .properties-field { 2 | & > .setting-item-info { 3 | flex: 0; 4 | margin: 0; 5 | padding: var(--size-4-1) var(--size-4-2); 6 | 7 | border: var(--input-border-width) solid var(--background-modifier-border); 8 | border-radius: var(--input-radius) 0 0 var(--input-radius); 9 | } 10 | 11 | & > .setting-item-control > input { 12 | width: 100%; 13 | 14 | border-radius: 0 var(--input-radius) var(--input-radius) 0; 15 | } 16 | } 17 | 18 | .ac-settings-heading { 19 | border-bottom: 1px solid var(--color-accent); 20 | 21 | &:not(:first-child) { 22 | margin-top: var(--size-4-10) !important; 23 | } 24 | 25 | &:has(.checkbox-container:not(.is-enabled)) { 26 | border-bottom-color: var(--background-modifier-border-hover); 27 | } 28 | 29 | .setting-item-description { 30 | margin-inline-end: 20px; 31 | } 32 | } 33 | 34 | .settings-header-children { 35 | transform-origin: top center; 36 | 37 | transform: scaleY(1); 38 | transition: transform 0.2s ease-in-out; 39 | } 40 | 41 | .ac-settings-heading:has(.checkbox-container:not(.is-enabled)) + .settings-header-children { 42 | opacity: 0.5; 43 | pointer-events: none; 44 | 45 | height: 0; 46 | transform: scaleY(0); 47 | } 48 | 49 | details.setting-item { 50 | &[open] > summary { 51 | margin-bottom: 0.75em; 52 | } 53 | 54 | & > *:not(summary) { 55 | padding-left: 1em; 56 | border-left: 1px solid var(--color-accent); 57 | } 58 | } 59 | 60 | body.is-mobile .kofi-button.sticky { 61 | display: none; // TODO: Fix this 62 | } 63 | 64 | .kofi-button { 65 | height: 30px; 66 | max-height: 30px; 67 | 68 | &.sticky { 69 | z-index: 10; 70 | 71 | position: absolute; 72 | bottom: var(--size-4-5); 73 | right: var(--size-4-5); 74 | } 75 | 76 | img { 77 | height: 100%; 78 | } 79 | } 80 | 81 | .kofi-overlay { 82 | z-index: 100; 83 | 84 | position: absolute; 85 | top: 0; 86 | left: 0; 87 | right: 0; 88 | bottom: 0; 89 | 90 | display: flex; 91 | justify-content: center; 92 | align-items: center; 93 | flex-direction: column; 94 | 95 | &::before { 96 | content: ""; 97 | z-index: -1; 98 | 99 | position: absolute; 100 | top: 0; 101 | left: 0; 102 | right: 0; 103 | bottom: 0; 104 | 105 | background-color: var(--color-base-00); 106 | opacity: 0.95; 107 | } 108 | 109 | h1 { 110 | margin-bottom: 0; 111 | } 112 | 113 | p { 114 | max-width: 50%; 115 | 116 | color: var(--text-muted); 117 | text-align: center; 118 | 119 | b { 120 | color: var(--text-primary); 121 | } 122 | } 123 | 124 | progress { 125 | margin-top: var(--size-4-8); 126 | background: transparent; 127 | 128 | &::-webkit-progress-bar { 129 | background-color: var(--background-modifier-border); 130 | border-radius: var(--input-radius); 131 | } 132 | 133 | &::-webkit-progress-value { 134 | background-color: var(--color-accent); 135 | border-radius: var(--input-radius); 136 | } 137 | } 138 | 139 | .kofi-button { 140 | margin-top: var(--size-4-10); 141 | margin-bottom: var(--size-4-5); 142 | } 143 | 144 | .no-button { 145 | cursor: pointer; 146 | } 147 | } -------------------------------------------------------------------------------- /src/utils/bbox-helper.ts: -------------------------------------------------------------------------------- 1 | import { Side } from "src/@types/AdvancedJsonCanvas" 2 | import { BBox, Position } from "src/@types/Canvas" 3 | 4 | export default class BBoxHelper { 5 | static combineBBoxes(bboxes: BBox[]): BBox { 6 | let minX = Infinity 7 | let minY = Infinity 8 | let maxX = -Infinity 9 | let maxY = -Infinity 10 | 11 | for (let bbox of bboxes) { 12 | minX = Math.min(minX, bbox.minX) 13 | minY = Math.min(minY, bbox.minY) 14 | maxX = Math.max(maxX, bbox.maxX) 15 | maxY = Math.max(maxY, bbox.maxY) 16 | } 17 | 18 | return { minX, minY, maxX, maxY } 19 | } 20 | 21 | static scaleBBox(bbox: BBox, scale: number): BBox { 22 | let diffX = (scale - 1) * (bbox.maxX - bbox.minX) 23 | let diffY = (scale - 1) * (bbox.maxY - bbox.minY) 24 | 25 | return { 26 | minX: bbox.minX - diffX / 2, 27 | maxX: bbox.maxX + diffX / 2, 28 | minY: bbox.minY - diffY / 2, 29 | maxY: bbox.maxY + diffY / 2 30 | } 31 | } 32 | 33 | static isColliding(bbox1: BBox, bbox2: BBox): boolean { 34 | return bbox1.minX <= bbox2.maxX && bbox1.maxX >= bbox2.minX && bbox1.minY <= bbox2.maxY && bbox1.maxY >= bbox2.minY 35 | } 36 | 37 | static insideBBox(position: Position | BBox, bbox: BBox, canTouchEdge: boolean): boolean { 38 | if ('x' in position) { 39 | const x = position.x, y = position.y 40 | return canTouchEdge 41 | ? x >= bbox.minX && x <= bbox.maxX && y >= bbox.minY && y <= bbox.maxY 42 | : x > bbox.minX && x < bbox.maxX && y > bbox.minY && y < bbox.maxY 43 | } 44 | 45 | return canTouchEdge 46 | ? position.minX >= bbox.minX && position.maxX <= bbox.maxX && 47 | position.minY >= bbox.minY && position.maxY <= bbox.maxY 48 | : position.minX > bbox.minX && position.maxX < bbox.maxX && 49 | position.minY > bbox.minY && position.maxY < bbox.maxY 50 | } 51 | 52 | static enlargeBBox(bbox: BBox, padding: number): BBox { 53 | return { 54 | minX: bbox.minX - padding, 55 | minY: bbox.minY - padding, 56 | maxX: bbox.maxX + padding, 57 | maxY: bbox.maxY + padding 58 | } 59 | } 60 | 61 | static moveInDirection(position: Position, side: Side, distance: number): Position { 62 | switch (side) { 63 | case 'top': 64 | return { x: position.x, y: position.y - distance } 65 | case 'right': 66 | return { x: position.x + distance, y: position.y } 67 | case 'bottom': 68 | return { x: position.x, y: position.y + distance } 69 | case 'left': 70 | return { x: position.x - distance, y: position.y } 71 | } 72 | } 73 | 74 | static getCenterOfBBoxSide(bbox: BBox, side: Side): Position { 75 | switch (side) { 76 | case 'top': 77 | return { x: (bbox.minX + bbox.maxX) / 2, y: bbox.minY } 78 | case 'right': 79 | return { x: bbox.maxX, y: (bbox.minY + bbox.maxY) / 2 } 80 | case 'bottom': 81 | return { x: (bbox.minX + bbox.maxX) / 2, y: bbox.maxY } 82 | case 'left': 83 | return { x: bbox.minX, y: (bbox.minY + bbox.maxY) / 2 } 84 | } 85 | } 86 | 87 | static getSideVector(side?: Side): Position { 88 | switch (side) { 89 | case 'top': 90 | return { x: 0, y: 1 } 91 | case 'right': 92 | return { x: 1, y: 0 } 93 | case 'bottom': 94 | return { x: 0, y: -1 } 95 | case 'left': 96 | return { x: -1, y: 0 } 97 | default: 98 | return { x: 0, y: 0 } 99 | } 100 | } 101 | 102 | static getOppositeSide(side: Side): Side { 103 | switch (side) { 104 | case 'top': 105 | return 'bottom' 106 | case 'right': 107 | return 'left' 108 | case 'bottom': 109 | return 'top' 110 | case 'left': 111 | return 'right' 112 | } 113 | } 114 | 115 | static isHorizontal(side: Side): boolean { 116 | return side === 'left' || side === 'right' 117 | } 118 | 119 | static direction(side: Side): number { 120 | return (side === 'right' || side === 'bottom') ? 1 : -1 121 | } 122 | } -------------------------------------------------------------------------------- /src/utils/debug-helper.ts: -------------------------------------------------------------------------------- 1 | import { BBox, Canvas, CanvasEdge, CanvasNode } from "src/@types/Canvas" 2 | import AdvancedCanvasPlugin from "src/main" 3 | 4 | export default class DebugHelper { 5 | plugin: AdvancedCanvasPlugin 6 | logging = true 7 | 8 | private nodeAddedCount = 0 9 | private nodeChangedCount = 0 10 | private edgeAddedCount = 0 11 | private edgeChangedCount = 0 12 | 13 | constructor(plugin: AdvancedCanvasPlugin) { 14 | this.plugin = plugin 15 | 16 | this.plugin.registerEvent(this.plugin.app.workspace.on( 17 | 'advanced-canvas:canvas-changed', 18 | (_canvas: Canvas) => { 19 | this.nodeAddedCount = 0 20 | this.nodeChangedCount = 0 21 | this.edgeAddedCount = 0 22 | this.edgeChangedCount = 0 23 | } 24 | )) 25 | 26 | this.plugin.registerEvent(this.plugin.app.workspace.on( 27 | 'advanced-canvas:node-added', 28 | (_canvas: Canvas, _node: CanvasNode) => { 29 | if (this.logging) console.count('🟢 NodeAdded') 30 | this.nodeAddedCount++ 31 | } 32 | )) 33 | 34 | this.plugin.registerEvent(this.plugin.app.workspace.on( 35 | 'advanced-canvas:node-changed', 36 | (_canvas: Canvas, _node: CanvasNode) => { 37 | if (this.logging) console.count('🟡 NodeChanged') 38 | this.nodeChangedCount++ 39 | } 40 | )) 41 | 42 | this.plugin.registerEvent(this.plugin.app.workspace.on( 43 | 'advanced-canvas:edge-added', 44 | (_canvas: Canvas, _edge: CanvasEdge) => { 45 | if (this.logging) console.count('🟢 EdgeAdded') 46 | this.edgeAddedCount++ 47 | } 48 | )) 49 | 50 | this.plugin.registerEvent(this.plugin.app.workspace.on( 51 | 'advanced-canvas:edge-changed', 52 | (_canvas: Canvas, _edge: CanvasEdge) => { 53 | if (this.logging) console.count('🟡 EdgeChanged') 54 | this.edgeChangedCount++ 55 | } 56 | )) 57 | } 58 | 59 | resetEfficiency() { 60 | this.nodeAddedCount = 0 61 | this.nodeChangedCount = 0 62 | this.edgeAddedCount = 0 63 | this.edgeChangedCount = 0 64 | } 65 | 66 | logEfficiency() { 67 | const canvas = this.plugin.getCurrentCanvas() 68 | if (!canvas) return 69 | 70 | console.log('NodeAdded Efficiency:', this.nodeAddedCount / canvas.nodes.size) 71 | console.log('NodeChanged Efficiency:', this.nodeChangedCount / canvas.nodes.size) 72 | 73 | console.log('EdgeAdded Efficiency:', this.edgeAddedCount / canvas.edges.size) 74 | console.log('EdgeChanged Efficiency:', this.edgeChangedCount / canvas.edges.size) 75 | } 76 | 77 | static markBBox(canvas: Canvas, bbox: BBox, duration: number = -1) { 78 | const node = canvas.createTextNode({ 79 | pos: { x: bbox.minX, y: bbox.minY }, 80 | size: { width: bbox.maxX - bbox.minX, height: bbox.maxY - bbox.minY }, 81 | text: '', 82 | focus: false 83 | }) 84 | 85 | node.setData({ 86 | ...node.getData(), 87 | id: 'debug-bbox', 88 | color: '1', 89 | styleAttributes: { 90 | border: 'invisible' 91 | } 92 | }) 93 | 94 | if (duration >= 0) { 95 | setTimeout(() => { 96 | canvas.removeNode(node) 97 | }, duration) 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /src/utils/filepath-helper.ts: -------------------------------------------------------------------------------- 1 | export default class FilepathHelper { 2 | static extension(path: string): string | undefined { 3 | return path.includes('.') ? path.split('.').pop() : undefined 4 | } 5 | } -------------------------------------------------------------------------------- /src/utils/hash-helper.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, TFile } from 'obsidian' 2 | 3 | export default class HashHelper { 4 | static async getFileHash(plugin: Plugin, file: TFile): Promise { 5 | const bytes = await plugin.app.vault.readBinary(file) 6 | const cryptoBytes = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)) 7 | return HashHelper.arrayBufferToHexString(cryptoBytes) 8 | } 9 | 10 | static arrayBufferToHexString(buffer: ArrayBuffer): string { 11 | const uint8Array = new Uint8Array(buffer) 12 | const hexArray = [] 13 | 14 | for (const byte of uint8Array) { 15 | hexArray.push((byte >>> 4).toString(16)) 16 | hexArray.push((byte & 0x0F).toString(16)) 17 | } 18 | 19 | return hexArray.join('') 20 | } 21 | } -------------------------------------------------------------------------------- /src/utils/icons-helper.ts: -------------------------------------------------------------------------------- 1 | import { addIcon } from "obsidian" 2 | 3 | const CUSTOM_ICONS = { 4 | 'shape-pill': ``, 5 | 'shape-parallelogram': ``, 6 | 'shape-predefined-process': ` 7 | 8 | 9 | 10 | 11 | 12 | `, 13 | 'shape-document': ``, 14 | 'shape-database': ` 15 | 16 | 17 | 18 | 19 | `, 20 | 21 | 'border-solid': ``, 22 | 'border-dashed': ``, 23 | 'border-dotted': ``, 24 | 25 | 'path-solid': ``, 26 | 'path-dotted': ``, 27 | 'path-short-dashed': ``, 28 | 'path-long-dashed': ``, 29 | 30 | 'arrow-triangle': ``, 31 | 'arrow-triangle-outline': ``, 32 | 'arrow-thin-triangle': ``, 33 | 'arrow-halved-triangle': ``, 34 | 'arrow-diamond': ``, 35 | 'arrow-diamond-outline': ``, 36 | 'arrow-circle': ``, 37 | 'arrow-circle-outline': ``, 38 | 39 | 'pathfinding-method-bezier': ``, 40 | 'pathfinding-method-square': ``, 41 | } 42 | 43 | export default class IconsHelper { 44 | static addIcons() { 45 | for (const [id, svg] of Object.entries(CUSTOM_ICONS)) { 46 | addIcon(id, svg) 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/utils/migration-helper.ts: -------------------------------------------------------------------------------- 1 | import { CanvasData, CanvasEdgeData } from "src/@types/AdvancedJsonCanvas" 2 | 3 | export const CURRENT_SPEC_VERSION = '1.0-1.0' 4 | 5 | export default class MigrationHelper { 6 | private static MIGRATIONS: Record { version: string, canvas: CanvasData }> = { 7 | undefined: (canvas: CanvasData) => { 8 | const TARGET_SPEC_VERSION = '1.0-1.0' 9 | 10 | // Move 'isStartNode' to metadata 11 | let startNode: string | undefined 12 | 13 | // Move 'edgesToNodeFromPortal' to 'interdimensionalEdges' 14 | const globalInterdimensionalEdges: { [portalId: string]: CanvasEdgeData[] } = {} 15 | 16 | // Rename node properties 17 | for (const node of canvas.nodes as any[]) { 18 | node.dynamicHeight = node.autoResizeHeight 19 | delete node.autoResizeHeight 20 | 21 | node.ratio = node.sideRatio 22 | delete node.sideRatio 23 | 24 | node.collapsed = node.isCollapsed 25 | delete node.isCollapsed 26 | 27 | if (node.portalToFile) { 28 | node.portal = true 29 | delete node.portalToFile 30 | } 31 | 32 | if (node.isStartNode) { 33 | startNode = node.id 34 | delete node.isStartNode 35 | } 36 | 37 | // edgesToNodeFromPortal?: { [key: string]: CanvasEdgeData[] } 38 | if (node.edgesToNodeFromPortal) { 39 | const edgesToNodeFromPortal = node.edgesToNodeFromPortal as { [portalId: string]: CanvasEdgeData[] } 40 | 41 | for (const [portalId, edges] of Object.entries(edgesToNodeFromPortal)) { 42 | // Create a new entry for the portal if it doesn't exist yet 43 | if (!(portalId in globalInterdimensionalEdges)) globalInterdimensionalEdges[portalId] = [] 44 | 45 | // Update edges 'fromNode'/'toNode' properties to differentiate which node is from the portal 46 | for (const edge of edges) { 47 | if (edge.fromNode !== node.id) edge.fromNode = `${portalId}-${edge.fromNode}` 48 | if (edge.toNode !== node.id) edge.toNode = `${portalId}-${edge.toNode}` 49 | } 50 | 51 | // Add edges to the global interdimensional edges 52 | globalInterdimensionalEdges[portalId].push(...edges) 53 | } 54 | 55 | delete node.edgesToNodeFromPortal 56 | } 57 | } 58 | 59 | // Distribute global interdimensional edges to portals 60 | for (const node of canvas.nodes as any[]) { 61 | if (!(node.id in globalInterdimensionalEdges)) continue 62 | node.interdimensionalEdges = globalInterdimensionalEdges[node.id] 63 | } 64 | 65 | // Add metadata node 66 | canvas.metadata ??= { 67 | version: TARGET_SPEC_VERSION, 68 | frontmatter: {}, 69 | startNode: startNode 70 | } 71 | 72 | return { version: TARGET_SPEC_VERSION, canvas: canvas } 73 | } 74 | } 75 | 76 | static needsMigration(canvas: CanvasData): boolean { 77 | return canvas.metadata?.version !== CURRENT_SPEC_VERSION 78 | } 79 | 80 | static migrate(canvas: CanvasData): CanvasData { 81 | let version = canvas.metadata?.version ?? 'undefined' 82 | 83 | // Already migrated 84 | if (version === CURRENT_SPEC_VERSION) return canvas 85 | 86 | // Migrate canvas while version is not the current version 87 | while (version !== CURRENT_SPEC_VERSION) { 88 | const migrationFunction = MigrationHelper.MIGRATIONS[version] 89 | if (!migrationFunction) { 90 | console.error(`No migration function found for version ${version}. Critical error!`) 91 | break 92 | } 93 | 94 | // Migrate canvas 95 | const { version: newVersion, canvas: migratedCanvas } = migrationFunction(canvas) 96 | 97 | // Update version and canvas 98 | version = newVersion as any 99 | canvas = migratedCanvas 100 | 101 | // Update metadata node 102 | if (!canvas.metadata) canvas.metadata = { version: version, frontmatter: {} } 103 | else canvas.metadata.version = version 104 | } 105 | 106 | return canvas 107 | } 108 | } -------------------------------------------------------------------------------- /src/utils/modal-helper.ts: -------------------------------------------------------------------------------- 1 | import App, { FuzzySuggestModal, SuggestModal, TFile } from 'obsidian' 2 | import FilepathHelper from 'src/utils/filepath-helper' 3 | 4 | export class AbstractSelectionModal extends FuzzySuggestModal { 5 | suggestions: string[] 6 | 7 | constructor(app: App, placeholder: string, suggestions: string[]) { 8 | super(app) 9 | 10 | this.suggestions = suggestions 11 | 12 | this.setPlaceholder(placeholder) 13 | this.setInstructions([{ 14 | command: '↑↓', 15 | purpose: 'to navigate' 16 | }, { 17 | command: 'esc', 18 | purpose: 'to dismiss' 19 | }]) 20 | } 21 | 22 | getItems(): string[] { return this.suggestions } 23 | getItemText(item: string): string { return item } 24 | 25 | onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void { } 26 | awaitInput(): Promise { 27 | return new Promise((resolve, _reject) => { 28 | this.onChooseItem = (item: string) => { resolve(item) } 29 | this.open() 30 | }) 31 | } 32 | } 33 | 34 | export class FileNameModal extends SuggestModal { 35 | parentPath: string 36 | fileExtension: string 37 | 38 | constructor(app: App, parentPath: string, fileExtension: string) { 39 | super(app) 40 | 41 | this.parentPath = parentPath.replace(/^\//, '').replace(/\/$/, '') 42 | this.fileExtension = fileExtension 43 | } 44 | 45 | getSuggestions(query: string): string[] { 46 | const queryWithoutExtension = query.replace(new RegExp(`\\.${this.fileExtension}$`), '') 47 | if (queryWithoutExtension === '') return [] 48 | 49 | const queryWithExtension = queryWithoutExtension + '.' + this.fileExtension 50 | const suggestions = [queryWithExtension] 51 | 52 | if (this.parentPath.length > 0) suggestions.splice(0, 0, `${this.parentPath}/${queryWithExtension}`) 53 | 54 | // Filter out suggestions for files that already exist 55 | return suggestions.filter(s => this.app.vault.getAbstractFileByPath(s) === null) 56 | } 57 | 58 | renderSuggestion(text: string, el: HTMLElement) { 59 | el.setText(text) 60 | } 61 | 62 | onChooseSuggestion(_text: string, _evt: MouseEvent | KeyboardEvent) {} 63 | 64 | awaitInput(): Promise { 65 | return new Promise((resolve, _reject) => { 66 | this.onChooseSuggestion = (text: string) => { resolve(text) } 67 | this.open() 68 | }) 69 | } 70 | } 71 | 72 | export class FileSelectModal extends SuggestModal { 73 | files: string[] 74 | suggestNewFile: boolean 75 | 76 | constructor(app: App, extensionsRegex?: RegExp, suggestNewFile: boolean = false) { 77 | super(app) 78 | 79 | this.files = this.app.vault.getFiles() 80 | .map(file => file.path) 81 | .filter(path => FilepathHelper.extension(path)?.match(extensionsRegex ?? /.*/)) 82 | this.suggestNewFile = suggestNewFile 83 | 84 | this.setPlaceholder('Type to search...') 85 | this.setInstructions([{ 86 | command: '↑↓', 87 | purpose: 'to navigate' 88 | }, { 89 | command: '↵', 90 | purpose: 'to open' 91 | }, { 92 | command: 'shift ↵', 93 | purpose: 'to create' 94 | }, { 95 | command: 'esc', 96 | purpose: 'to dismiss' 97 | }]) 98 | 99 | this.scope.register(['Shift'], 'Enter', ((e) => { 100 | this.onChooseSuggestion(this.inputEl.value, e) 101 | this.close() 102 | })) 103 | } 104 | 105 | getSuggestions(query: string): string[] { 106 | const suggestions = this.files.filter(path => path.toLowerCase().includes(query.toLowerCase())) 107 | if (suggestions.length === 0 && this.suggestNewFile) suggestions.push(query) 108 | 109 | return suggestions 110 | } 111 | 112 | renderSuggestion(path: string, el: HTMLElement) { 113 | const simplifiedPath = path.replace(/\.md$/, '') 114 | el.setText(simplifiedPath) 115 | } 116 | 117 | onChooseSuggestion(_path: string, _evt: MouseEvent | KeyboardEvent) {} 118 | 119 | awaitInput(): Promise { 120 | return new Promise((resolve, _reject) => { 121 | this.onChooseSuggestion = (path: string, _evt: MouseEvent | KeyboardEvent) => { 122 | const file = this.app.vault.getAbstractFileByPath(path) 123 | 124 | if (file instanceof TFile) 125 | return resolve(file) 126 | 127 | if (!this.suggestNewFile) return 128 | 129 | if (FilepathHelper.extension(path) === undefined) path += '.md' 130 | const newFile = this.app.vault.create(path, '') 131 | resolve(newFile) 132 | } 133 | 134 | this.open() 135 | }) 136 | } 137 | } -------------------------------------------------------------------------------- /src/utils/svg-path-helper.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "src/@types/Canvas" 2 | 3 | export default class SvgPathHelper { 4 | static smoothenPathArray(positions: Position[], tension: number): Position[] { 5 | let newPositions = [...positions] 6 | if (positions.length <= 2) return newPositions 7 | 8 | newPositions = [positions[0]] 9 | 10 | for (let i = 1; i < positions.length - 2; i++) { 11 | const p1 = positions[i] 12 | const p2 = positions[i + 1] 13 | const p3 = positions[i + 2] 14 | 15 | const t1 = (1 - tension) / 2 16 | const t2 = 1 - t1 17 | 18 | const x = 19 | t2 * t2 * t2 * p1.x + 20 | 3 * t2 * t2 * t1 * p2.x + 21 | 3 * t2 * t1 * t1 * p3.x + 22 | t1 * t1 * t1 * p2.x 23 | 24 | const y = 25 | t2 * t2 * t2 * p1.y + 26 | 3 * t2 * t2 * t1 * p2.y + 27 | 3 * t2 * t1 * t1 * p3.y + 28 | t1 * t1 * t1 * p2.y 29 | 30 | newPositions.push({ x: x, y: y }) 31 | } 32 | 33 | const lastPoint = positions[positions.length - 1] 34 | newPositions.push(lastPoint) 35 | 36 | return newPositions 37 | } 38 | 39 | static pathArrayToSvgPath(positions: Position[]): string { 40 | for (let i = 0; i < positions.length - 2; i++) { 41 | const p1 = positions[i] 42 | const p2 = positions[i + 1] 43 | const p3 = positions[i + 2] 44 | 45 | const currentDirection = { 46 | x: p2.x - p1.x, 47 | y: p2.y - p1.y 48 | } 49 | 50 | const nextDirection = { 51 | x: p3.x - p2.x, 52 | y: p3.y - p2.y 53 | } 54 | 55 | if (currentDirection.x !== nextDirection.x && currentDirection.y !== nextDirection.y) continue 56 | 57 | positions.splice(i + 1, 1) 58 | i-- 59 | } 60 | 61 | return positions.map((position, index) => 62 | `${index === 0 ? 'M' : 'L'} ${position.x} ${position.y}` 63 | ).join(' ') 64 | } 65 | 66 | static pathArrayToRoundedSvgPath(pathArray: Position[], targetRadius: number): string { 67 | if (pathArray.length < 3) 68 | return this.pathArrayToSvgPath(pathArray) 69 | 70 | // Remove duplicate points 71 | pathArray = pathArray.filter((position, index) => { 72 | if (index === 0) return true 73 | 74 | const previous = pathArray[index - 1] 75 | return !(position.x === previous.x && position.y === previous.y) 76 | }) 77 | 78 | const commands: string[] = [] 79 | commands.push(`M ${pathArray[0].x} ${pathArray[0].y}`) 80 | 81 | for (let i = 1; i < pathArray.length - 1; i++) { 82 | const previous = pathArray[i - 1] 83 | const current = pathArray[i] 84 | const next = pathArray[i + 1] 85 | 86 | const prevDelta = { x: current.x - previous.x, y: current.y - previous.y } 87 | const nextDelta = { x: next.x - current.x, y: next.y - current.y } 88 | const prevLen = Math.sqrt(prevDelta.x * prevDelta.x + prevDelta.y * prevDelta.y) 89 | const nextLen = Math.sqrt(nextDelta.x * nextDelta.x + nextDelta.y * nextDelta.y) 90 | 91 | const prevUnit = prevLen ? { x: prevDelta.x / prevLen, y: prevDelta.y / prevLen } : { x: 0, y: 0 } 92 | const nextUnit = nextLen ? { x: nextDelta.x / nextLen, y: nextDelta.y / nextLen } : { x: 0, y: 0 } 93 | 94 | let dot = prevUnit.x * nextUnit.x + prevUnit.y * nextUnit.y 95 | dot = Math.max(-1, Math.min(1, dot)) 96 | const angle = Math.acos(dot) 97 | 98 | // if the angle is nearly 0 (or almost straight) no rounding is needed 99 | if (angle < 0.01 || Math.abs(Math.PI - angle) < 0.01) { 100 | commands.push(`L ${current.x} ${current.y}`) 101 | continue 102 | } 103 | 104 | // compute the desired offset along the segments for the target radius 105 | const desiredOffset = targetRadius * Math.tan(angle / 2) 106 | // clamp the offset to half of each adjacent segment so it doesn't overshoot 107 | const d = Math.min(desiredOffset, prevLen / 2, nextLen / 2) 108 | // recalc the effective radius in case d was clamped 109 | const effectiveRadius = d / Math.tan(angle / 2) 110 | 111 | const firstAnchor = { 112 | x: current.x - prevUnit.x * d, 113 | y: current.y - prevUnit.y * d 114 | } 115 | const secondAnchor = { 116 | x: current.x + nextUnit.x * d, 117 | y: current.y + nextUnit.y * d 118 | } 119 | 120 | commands.push(`L ${firstAnchor.x} ${firstAnchor.y}`) 121 | 122 | // determine the sweep flag using the cross product 123 | const cross = prevDelta.x * nextDelta.y - prevDelta.y * nextDelta.x 124 | const sweepFlag = cross < 0 ? 0 : 1 125 | 126 | commands.push(`A ${effectiveRadius} ${effectiveRadius} 0 0 ${sweepFlag} ${secondAnchor.x} ${secondAnchor.y}`) 127 | } 128 | 129 | const last = pathArray[pathArray.length - 1] 130 | commands.push(`L ${last.x} ${last.y}`) 131 | 132 | return commands.join(' ') 133 | } 134 | } -------------------------------------------------------------------------------- /src/utils/text-helper.ts: -------------------------------------------------------------------------------- 1 | export default class TextHelper { 2 | static toCamelCase(str: string): string { 3 | return str.replace(/-./g, (x: string) => x[1].toUpperCase()) 4 | } 5 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "strictNullChecks": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------