├── .eslintignore ├── versions.json ├── media └── persistent-graph.gif ├── .editorconfig ├── .gitignore ├── manifest.json ├── tsconfig.json ├── .eslintrc ├── package.json ├── types.ts ├── LICENSE ├── README.md ├── esbuild.config.mjs └── main.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.1": "0.9.12", 3 | "1.0.0": "0.9.7" 4 | } 5 | -------------------------------------------------------------------------------- /media/persistent-graph.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sanqui/obsidian-persistent-graph/HEAD/media/persistent-graph.gif -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | insert_final_newline = true 7 | indent_style = tab 8 | indent_size = 4 9 | tab_width = 4 10 | -------------------------------------------------------------------------------- /.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 file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "persistent-graph", 3 | "name": "Persistent Graph", 4 | "version": "0.1.5", 5 | "minAppVersion": "0.12.0", 6 | "description": "Adds commands to save and restore the positions of nodes on the global graph view", 7 | "author": "Sanqui", 8 | "authorUrl": "https://sanqui.net", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /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 | "lib": [ 14 | "DOM", 15 | "ES5", 16 | "ES6", 17 | "ES7" 18 | ] 19 | }, 20 | "include": [ 21 | "**/*.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "parserOptions": { 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "no-unused-vars": "off", 17 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 18 | "@typescript-eslint/ban-ts-comment": "off", 19 | "no-prototype-builtins": "off", 20 | "@typescript-eslint/no-empty-function": "off" 21 | } 22 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "persistent-graph", 3 | "version": "0.2.0", 4 | "description": "An Obsidian plugin for persisting graph state", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "check": "tsc -noEmit -skipLibCheck", 9 | "build": "node esbuild.config.mjs production" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "^5.2.0", 17 | "@typescript-eslint/parser": "^5.2.0", 18 | "builtin-modules": "^3.2.0", 19 | "esbuild": "0.13.12", 20 | "obsidian": "^0.12.17", 21 | "tslib": "2.3.1", 22 | "typescript": "4.4.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceLeaf } from "obsidian"; 2 | 3 | export interface NodePosition { 4 | id: string; 5 | x: number, 6 | y: number; 7 | } 8 | 9 | export interface GraphData { 10 | options: any; 11 | nodePositions: NodePosition[]; 12 | } 13 | 14 | 15 | export interface GraphLeaf { 16 | view: { 17 | renderer: { 18 | idleFrames: number; 19 | /** Graph View requestAnimationFrame timer */ 20 | renderTimer: number; 21 | nodes: NodePosition[], 22 | worker: Worker, 23 | autoRestored: boolean; 24 | }; 25 | dataEngine: { 26 | controlsEl: HTMLDivElement, 27 | getOptions(): any, 28 | setOptions(options: any): void 29 | } 30 | }; 31 | } 32 | 33 | export type CustomLeaf = WorkspaceLeaf & GraphLeaf; 34 | 35 | export interface WorkspacesInstance { 36 | activeWorkspace: string; 37 | workspaces: { 38 | [activeWorkspace: string]: any; 39 | }; 40 | } 41 | 42 | export interface Workspaces { 43 | instance: WorkspacesInstance; 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sanqui 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Obsidian Persistent Graph Plugin 2 | 3 | This is a plugin for Obsidian (https://obsidian.md). 4 | 5 | Graph lovers, rejoice! 6 | 7 | Do you love using the global graph as a powerful spatial tool, but cry every time Obsidian restarts and all nodes lose their place? Would you like to retain the shape of your graph over a long time? Well then this plugin is for you. 8 | 9 | ![Demo video](media/persistent-graph.gif) 10 | 11 | This plugin adds commands to save and restore the locations of nodes on your graph. There's also a setting to restore automatically whenever you open a new graph view. That's it, it's that simple! And as a bonus I also added a command to continuously run the graph simulation so you don't have to jiggle it. 12 | 13 | Please note that this plugin makes use of internal Obsidian APIs, so it may break with updates, and it's unlikely to be accepted as a community plugin. This feature is so important to me that I built it nonetheless, so if you're brave you can enjoy it with me! 14 | 15 | Possible future features: 16 | - Automatic/periodic saving 17 | - Restoring when graph view is open 18 | - Save & restore graph settings like filters 19 | - Locking nodes in place -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === 'production'); 13 | 14 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/closebrackets', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/comment', 28 | '@codemirror/fold', 29 | '@codemirror/gutter', 30 | '@codemirror/highlight', 31 | '@codemirror/history', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/panel', 36 | '@codemirror/rangeset', 37 | '@codemirror/rectangular-selection', 38 | '@codemirror/search', 39 | '@codemirror/state', 40 | '@codemirror/stream-parser', 41 | '@codemirror/text', 42 | '@codemirror/tooltip', 43 | '@codemirror/view', 44 | ...builtins], 45 | format: 'cjs', 46 | watch: !prod, 47 | target: 'es2016', 48 | logLevel: "info", 49 | sourcemap: prod ? false : 'inline', 50 | treeShaking: true, 51 | outfile: 'main.js', 52 | }).catch(() => process.exit(1)); 53 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; 2 | import { CustomLeaf, NodePosition, Workspaces, GraphData } from 'types'; 3 | 4 | interface PersistentGraphSettings { 5 | nodePositions: NodePosition[]; 6 | globalOptions: any, 7 | workspacesGraphData: { 8 | [key: string]: GraphData; 9 | }; 10 | automaticallyRestoreNodePositions: boolean; 11 | enableSaveOptions: boolean; 12 | enableWorkspaces: boolean; 13 | timesShowedRestoredNotification: number; 14 | } 15 | 16 | const DEFAULT_SETTINGS: PersistentGraphSettings = { 17 | nodePositions: [], 18 | globalOptions: {}, 19 | workspacesGraphData: {}, 20 | automaticallyRestoreNodePositions: false, 21 | enableSaveOptions: false, 22 | enableWorkspaces: false, 23 | timesShowedRestoredNotification: 0, 24 | }; 25 | 26 | export default class PersistentGraphPlugin extends Plugin { 27 | settings: PersistentGraphSettings; 28 | 29 | findGraphLeaf(): CustomLeaf { 30 | let activeLeaf = this.app.workspace.activeLeaf; 31 | if (activeLeaf.view.getViewType() === 'graph') { 32 | return activeLeaf as CustomLeaf; 33 | } 34 | 35 | let graphLeaves = this.app.workspace.getLeavesOfType('graph'); 36 | if (graphLeaves.length != 1) { 37 | if (graphLeaves.length < 1) { 38 | new Notice('No graph view open'); 39 | } else { 40 | new Notice('More than one graph view open, please choose an active one'); 41 | } 42 | return; 43 | } 44 | return graphLeaves[0] as CustomLeaf; 45 | } 46 | 47 | getActiveWorkspaceName() { 48 | if (!this.settings.enableWorkspaces) { 49 | return null; 50 | } 51 | const workspaces = (this.app as any).internalPlugins.getPluginById('workspaces') as Workspaces; 52 | 53 | return workspaces?.instance.activeWorkspace; 54 | } 55 | 56 | getGraphData(): GraphData { 57 | return this.settings.workspacesGraphData[this.getActiveWorkspaceName()] || { 58 | nodePositions: this.settings.nodePositions, 59 | options: {} 60 | }; 61 | } 62 | 63 | saveGraphData() { 64 | let graphLeaf = this.findGraphLeaf(); 65 | if (!graphLeaf) return; 66 | 67 | // disable workspaces wName = null 68 | const wName = this.getActiveWorkspaceName(); 69 | const nodes = graphLeaf.view.renderer.nodes.map((node) => { 70 | return { 71 | id: node.id, 72 | x: node.x, 73 | y: node.y 74 | }; 75 | }); 76 | 77 | const options = !this.settings.enableSaveOptions ? {} : graphLeaf.view.dataEngine.getOptions(); 78 | 79 | if (wName) { 80 | this.settings.workspacesGraphData[wName] = { 81 | options, 82 | nodePositions: nodes 83 | }; 84 | return; 85 | } 86 | 87 | this.settings.nodePositions = nodes; 88 | this.settings.globalOptions = options; 89 | } 90 | 91 | restoreGraphData(saved: GraphData, graphLeaf?: CustomLeaf) { 92 | if (graphLeaf === undefined) { 93 | graphLeaf = this.findGraphLeaf(); 94 | } 95 | if (!graphLeaf) return; 96 | 97 | const { nodePositions } = saved; 98 | nodePositions.forEach((node) => { 99 | graphLeaf.view.renderer.worker.postMessage({ 100 | forceNode: node, 101 | }); 102 | }); 103 | 104 | // force a redraw 105 | graphLeaf.view.renderer.worker.postMessage({ 106 | run: true, 107 | alpha: .1 108 | }); 109 | 110 | // wait for a render, then unlock nodes 111 | setTimeout(async () => { 112 | for (let i = 0; i < nodePositions.length; i++) { 113 | const node = nodePositions[i]; 114 | 115 | if (!graphLeaf) return; 116 | graphLeaf.view.renderer.worker.postMessage({ 117 | forceNode: { 118 | id: node.id, 119 | x: null, 120 | y: null 121 | } 122 | }); 123 | } // end for 124 | 125 | if (this.settings.timesShowedRestoredNotification < 5 ) { 126 | new Notice('Automatically restored node positions'); 127 | this.settings.timesShowedRestoredNotification++; 128 | await this.saveSettings(); 129 | } 130 | }, 600); 131 | } 132 | 133 | freedWorkspacesData() { 134 | if (!this.settings.enableWorkspaces) { 135 | return; 136 | } 137 | const workspaces = (this.app as any).internalPlugins.getPluginById('workspaces') as Workspaces; 138 | const workspacesNames = Object.keys(workspaces?.instance.workspaces || {}); 139 | if (!workspacesNames) { 140 | return; 141 | } 142 | const saveNames = Object.keys(this.settings.workspacesGraphData); 143 | saveNames.forEach(name => { 144 | if (workspacesNames.includes(name)) { 145 | return; 146 | } 147 | 148 | delete this.settings.workspacesGraphData[name]; 149 | }); 150 | this.saveSettings(); 151 | } 152 | 153 | runGraphSimulation() { 154 | let graphLeaf = this.findGraphLeaf(); 155 | if (!graphLeaf) return; 156 | graphLeaf.view.renderer.worker.postMessage({ 157 | run: true, 158 | alpha: 1, 159 | alphaTarget: 1 160 | }); 161 | } 162 | 163 | stopGraphSimulation() { 164 | let graphLeaf = this.findGraphLeaf(); 165 | if (!graphLeaf) return; 166 | graphLeaf.view.renderer.worker.postMessage({ 167 | run: true, 168 | alpha: 0, 169 | alphaTarget: 0 170 | }); 171 | } 172 | 173 | onLayoutChange() { 174 | const activeLeaf = this.app.workspace.activeLeaf as CustomLeaf; 175 | 176 | if (activeLeaf.view.getViewType() != 'graph' || activeLeaf.view.renderer.autoRestored) { 177 | return; 178 | } 179 | 180 | activeLeaf.view.renderer.autoRestored = true; 181 | 182 | 183 | // avoid rerender 184 | const { options } = this.getGraphData(); 185 | let initOptions = {}; 186 | if (options && options.hasOwnProperty('search')) { 187 | initOptions = options; 188 | } 189 | activeLeaf.view.dataEngine.setOptions({ 190 | ...initOptions, 191 | 'collapse-filter': false, 192 | }); 193 | 194 | // We can't restore node positions right away 195 | // because not all nodes have been created yet. 196 | // So we wait for the node count to stabilize 197 | // over 600s. 198 | setTimeout(() => this.awaitRenderLoaded(activeLeaf), 600); 199 | } 200 | 201 | awaitRenderLoaded(activeLeaf: CustomLeaf) { 202 | window.requestAnimationFrame(() => { 203 | // The graph adds className 'is-loading' to the element when rendering. 204 | if (activeLeaf.view.containerEl.querySelector('.mod-search-setting')?.classList?.contains('is-loading')) { 205 | this.awaitRenderLoaded(activeLeaf); 206 | return; 207 | } 208 | this.restoreOnceNodeCountStable(activeLeaf, 0, 0, 0); 209 | }); 210 | } 211 | 212 | async restoreOnceNodeCountStable(leaf: CustomLeaf, nodeCount: number, iterations: number, totalIterations: number) { 213 | if (!leaf || !leaf.view || !leaf.view.renderer) { 214 | return; 215 | } 216 | // If we took too long, bail, we don't want to have this go forever 217 | if (totalIterations > 20) { 218 | return; 219 | } 220 | 221 | if (this.settings.automaticallyRestoreNodePositions) { 222 | const currentNodeCount = leaf.view.renderer.nodes.length; 223 | 224 | if (currentNodeCount === nodeCount) { 225 | if (iterations >= 3) { 226 | this.restoreGraphData(this.getGraphData(), leaf); 227 | } else { 228 | setTimeout(() => { 229 | this.restoreOnceNodeCountStable(leaf, currentNodeCount, iterations + 1, totalIterations + 1); 230 | }, 200); 231 | } 232 | } else { 233 | setTimeout(() => { 234 | this.restoreOnceNodeCountStable(leaf, currentNodeCount, 0, totalIterations + 1); 235 | }, 200); 236 | } 237 | } 238 | } 239 | 240 | async onload() { 241 | await this.loadSettings(); 242 | 243 | this.addCommand({ 244 | id: 'save-node-positions', 245 | name: 'Save graph node positions', 246 | callback: async () => { 247 | this.saveGraphData(); 248 | await this.saveSettings(); 249 | } 250 | }); 251 | 252 | this.addCommand({ 253 | id: 'restore-node-positions', 254 | name: 'Restore graph node positions', 255 | callback: () => { 256 | this.restoreGraphData(this.getGraphData()); 257 | } 258 | }); 259 | 260 | this.addCommand({ 261 | id: 'run-graph-simulation', 262 | name: 'Run graph simulation', 263 | callback: () => { 264 | this.runGraphSimulation(); 265 | } 266 | }); 267 | 268 | this.addCommand({ 269 | id: 'stop-graph-simulation', 270 | name: 'Stop graph simulation', 271 | callback: () => { 272 | this.stopGraphSimulation(); 273 | } 274 | }); 275 | 276 | this.addSettingTab(new PersistentGraphSettingTab(this.app, this)); 277 | 278 | // active-leaf-change works for the most part, but doesn't fire 279 | // when going from 'No file is open', so we have to use layout-change 280 | this.registerEvent( 281 | this.app.workspace.on('layout-change', this.onLayoutChange.bind(this)) 282 | ); 283 | 284 | this.registerEvent( 285 | this.app.metadataCache.on('resolved', this.freedWorkspacesData.bind(this)) 286 | ); 287 | } 288 | 289 | onunload() { 290 | } 291 | 292 | async loadSettings() { 293 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 294 | } 295 | 296 | async saveSettings() { 297 | await this.saveData(this.settings); 298 | } 299 | } 300 | 301 | class PersistentGraphSettingTab extends PluginSettingTab { 302 | plugin: PersistentGraphPlugin; 303 | 304 | constructor(app: App, plugin: PersistentGraphPlugin) { 305 | super(app, plugin); 306 | this.plugin = plugin; 307 | } 308 | 309 | display(): void { 310 | const { containerEl } = this; 311 | 312 | containerEl.empty(); 313 | 314 | containerEl.createEl('h2', { text: 'Settings for PersistentGraphPlugin' }); 315 | 316 | this.UIAutomaticallyRestoreNodePositions(); 317 | this.UIEnableSaveOptions(); 318 | this.UIEnableWorkspaces(); 319 | } 320 | 321 | UIAutomaticallyRestoreNodePositions() { 322 | const { containerEl } = this; 323 | 324 | new Setting(containerEl) 325 | .setName('Automatically restore node positions') 326 | .setDesc('Restore node positions every time a graph view is opened') 327 | .addToggle((toggle) => 328 | toggle 329 | .setValue(this.plugin.settings.automaticallyRestoreNodePositions) 330 | .onChange((value) => { 331 | this.plugin.settings.automaticallyRestoreNodePositions = value; 332 | this.plugin.saveSettings(); 333 | }) 334 | ); 335 | } 336 | 337 | UIEnableSaveOptions() { 338 | const { containerEl } = this; 339 | 340 | new Setting(containerEl) 341 | .setName('Save the filtered configuration') 342 | .setDesc('Filters, Groups, Display, Forces') 343 | .addToggle((toggle) => 344 | toggle 345 | .setValue(this.plugin.settings.enableSaveOptions) 346 | .onChange((value) => { 347 | this.plugin.settings.enableSaveOptions = value; 348 | this.plugin.saveSettings(); 349 | }) 350 | ); 351 | } 352 | 353 | UIEnableWorkspaces() { 354 | const { containerEl } = this; 355 | // Save graph layout separately for each workspace 356 | new Setting(containerEl) 357 | .setName('Save graph layout separately for each workspace') 358 | .setDesc('Use workspace name as storage key') 359 | .addToggle((toggle) => 360 | toggle 361 | .setValue(this.plugin.settings.enableWorkspaces) 362 | .onChange((value) => { 363 | this.plugin.settings.enableWorkspaces = value; 364 | this.plugin.saveSettings(); 365 | }) 366 | ); 367 | } 368 | 369 | 370 | } 371 | --------------------------------------------------------------------------------