├── .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 |
--------------------------------------------------------------------------------
/assets/docs/document-shape.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------