/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // main.ts var main_exports = {}; __export(main_exports, { default: () => EnhancedCanvas }); module.exports = __toCommonJS(main_exports); var import_obsidian4 = require("obsidian"); // node_modules/monkey-around/dist/index.mjs function around(obj, factories) { const removers = Object.keys(factories).map((key) => around1(obj, key, factories[key])); return removers.length === 1 ? removers[0] : function() { removers.forEach((r) => r()); }; } function around1(obj, method, createWrapper) { const inherited = obj[method], hadOwn = obj.hasOwnProperty(method), original = hadOwn ? inherited : function() { return Object.getPrototypeOf(obj)[method].apply(this, arguments); }; let current = createWrapper(original); if (inherited) Object.setPrototypeOf(current, inherited); Object.setPrototypeOf(wrapper, current); obj[method] = wrapper; return remove; function wrapper(...args) { if (current === original && obj[method] === wrapper) remove(); return current.apply(this, args); } function remove() { if (obj[method] === wrapper) { if (hadOwn) obj[method] = original; else delete obj[method]; } if (current === original) return; current = original; Object.setPrototypeOf(wrapper, inherited || Function); } } // src/CanvasExploder.ts var import_obsidian = require("obsidian"); var HEADING_LIMIT = 10; var COMPACT_HEIGHT = 50; var LEAF_HEIGHT = 170; var DEFAULT_WIDTH = 400; var MAX_HEIGHT = 600; var GAP_Y = 20; var GAP_X = 20; var CanvasExploder = class { constructor(plugin) { this.plugin = plugin; } checkAndAddMenu(menu, title) { const activeView = this.plugin.app.workspace.getActiveViewOfType(import_obsidian.ItemView); if (activeView && activeView.getViewType() === "canvas") { const canvas = activeView.canvas; if (canvas) { const selection = canvas.selection; if (selection.size === 1) { const node = selection.values().next().value; if (node && node.file) { menu.addItem((item) => { item.setTitle(title).setIcon("expand").onClick(() => { this.explodeFileNode(canvas, node); }); }); } } } } } randomId(length = 16) { const byteLength = Math.ceil(length / 2); const array = new Uint8Array(byteLength); window.crypto.getRandomValues(array); return Array.from( array, (byte) => byte.toString(16).padStart(2, "0") ).join("").substring(0, length); } sanitizeHeading(rawHeading) { let text = rawHeading.replace(/\[\[|\]\]/g, ""); text = text.replace(/[|#:]/g, " "); text = text.replace(/\s+/g, " "); return text.trim(); } /** * Deconstructs a single file node into a hierarchical tree of connected nodes representing its internal headings, * replacing the original node to visualize the document's structure directly on the canvas. */ async explodeFileNode(canvas, originalNode) { var _a; const targetFile = originalNode.file; const cache = this.plugin.app.metadataCache.getFileCache(targetFile); if (!cache || !cache.headings || cache.headings.length === 0) { new import_obsidian.Notice(`File "${targetFile.basename}" does not contain any headings to explode.`); return; } const headings = cache.headings; const isCompactMode = headings.length > HEADING_LIMIT; const baseX = originalNode.x; let currentY = originalNode.y; const width = Math.max((_a = originalNode.width) != null ? _a : 0, DEFAULT_WIDTH); const minLevel = Math.min(...headings.map((h) => h.level)); const nodeStack = []; const edgesToAdd = []; const newNodesSet = /* @__PURE__ */ new Set(); let createdCount = 0; for (let i = 0; i < headings.length; i++) { const heading = headings[i]; const nextHeading = headings[i + 1]; const isParent = nextHeading && nextHeading.level > heading.level; let nodeHeight; if (isParent) { nodeHeight = COMPACT_HEIGHT; } else if (isCompactMode) { nodeHeight = LEAF_HEIGHT; } else { nodeHeight = typeof originalNode.height === "number" && originalNode.height > COMPACT_HEIGHT ? Math.min(MAX_HEIGHT, originalNode.height) : COMPACT_HEIGHT; } const levelOffset = heading.level - minLevel; const currentX = baseX + levelOffset * (width + GAP_X); const cleanText = this.sanitizeHeading(heading.heading); const subpath = `#${cleanText}`; let newNode; try { newNode = canvas.createFileNode({ file: targetFile, subpath, pos: { x: currentX, y: currentY }, size: { width, height: nodeHeight }, save: false, focus: false }); } catch (e) { console.error(`Failed to create node for heading: ${subpath}`, e); continue; } if (!newNode) continue; createdCount++; newNodesSet.add(newNode); while (nodeStack.length > 0) { const lastEntry = nodeStack[nodeStack.length - 1]; if (lastEntry.level >= heading.level) { nodeStack.pop(); } else { break; } } if (nodeStack.length > 0) { const parentEntry = nodeStack[nodeStack.length - 1]; edgesToAdd.push({ id: this.randomId(), fromNode: parentEntry.nodeId, fromSide: "bottom", toNode: newNode.id, toSide: "left" }); } nodeStack.push({ level: heading.level, nodeId: newNode.id }); currentY += nodeHeight + GAP_Y; } if (edgesToAdd.length > 0) { const currentData = canvas.getData(); currentData.edges.push(...edgesToAdd); canvas.setData(currentData); } if (createdCount > 0) { canvas.removeNode(originalNode); } if (newNodesSet.size > 0) { canvas.deselectAll(); for (const node of newNodesSet) { canvas.select(node); } canvas.zoomToSelection(); } canvas.requestSave(); new import_obsidian.Notice(`Explosion complete, created ${createdCount} nodes.`); } }; // src/SendToCanvas.ts var import_obsidian2 = require("obsidian"); var SendToCanvas = class { constructor(plugin) { this.selectedCanvas = null; this.statusBarItemEl = null; this.plugin = plugin; } handleSendToCanvas() { const currentFile = this.getCurrentMarkdownFile(); if (!currentFile) { new import_obsidian2.Notice("Please open a Markdown file to send to the Canvas."); return; } this.promptCanvasSelectionAndInsert(currentFile); } handleSendToSelectedCanvas() { const currentFile = this.getCurrentMarkdownFile(); if (!currentFile) { new import_obsidian2.Notice("Please open a Markdown file to send to the Canvas."); return; } if (this.selectedCanvas) { this.addFileNodeToCanvas(currentFile, this.selectedCanvas); new import_obsidian2.Notice(`Using previously selected Canvas: ${this.selectedCanvas.name}`); } else { new import_obsidian2.Notice(`Failed to send. No Canvas file selected yet.`); this.promptCanvasSelectionAndInsert(currentFile); } } getCurrentMarkdownFile() { const leaf = this.plugin.app.workspace.activeLeaf; if (!leaf || !(leaf.view.file instanceof import_obsidian2.TFile) || leaf.view.file.extension !== "md") { return null; } return leaf.view.file; } promptCanvasSelectionAndInsert(targetFile) { const canvasFiles = this.getCanvasFiles(); if (!canvasFiles.length) { new import_obsidian2.Notice("No Canvas files found in vault."); return; } const modal = new CanvasFileSuggestModal( this.plugin.app, canvasFiles, (canvasFile) => { this.selectedCanvas = canvasFile; this.updateStatusBar(); this.addFileNodeToCanvas(targetFile, canvasFile); } ); modal.open(); } /** * Incorporates a file into a Canvas board as a visual node and establishes a bidirectional reference between the document and the workspace. * * @param targetFile - The file to be added to the visualization. * @param canvasFile - The target Canvas file. */ async addFileNodeToCanvas(targetFile, canvasFile) { if (!canvasFile || !canvasFile.name) return; const canvasContent = await this.plugin.app.vault.read(canvasFile); let canvasData; try { canvasData = JSON.parse(canvasContent || '{"nodes":[], "edges":[]}'); } catch (e) { new import_obsidian2.Notice(`Error reading Canvas JSON for ${canvasFile.name}.`); console.error("Canvas JSON Parse Error:", e); return; } if (!Array.isArray(canvasData.nodes)) canvasData.nodes = []; if (!Array.isArray(canvasData.edges)) canvasData.edges = []; const existingNode = canvasData.nodes.find((node) => node.type === "file" && node.file === targetFile.path); if (existingNode) { new import_obsidian2.Notice(`${targetFile.basename} already exists in Canvas.`); return; } const newNode = this.createNodeAtBottom(targetFile, canvasData.nodes); canvasData.nodes.push(newNode); const updatedContent = JSON.stringify(canvasData, null, 2); try { await this.plugin.app.vault.modify(canvasFile, updatedContent); const internalLink = `[[${canvasFile.name}]]`; await this.plugin.updateFrontmatter(targetFile, internalLink, "add", "canvas"); new import_obsidian2.Notice(`Added ${targetFile.basename} to Canvas: ${canvasFile.basename}`); } catch (e) { new import_obsidian2.Notice(`Failed to add to Canvas: ${canvasFile.name}`); console.error("Canvas Modify Error:", e); } } /** * Creates a new node for a file at the bottom of the canvas. * * @param file - The file to create a node for. * @param existingNodes - The existing nodes in the canvas. * @returns The new node. */ createNodeAtBottom(file, existingNodes) { const id = this.randomId(); const WIDTH = 400; const HEIGHT = 400; const GAP = 100; const DEFAULT_X = -200; let startY = -200; if (existingNodes.length > 0) { const maxY = existingNodes.reduce((max, node) => { const bottomEdge = node.y + node.height; return bottomEdge > max ? bottomEdge : max; }, -Infinity); if (maxY !== -Infinity) { startY = maxY + GAP; } } return { id, x: DEFAULT_X, y: startY, width: WIDTH, height: HEIGHT, type: "file", file: file.path }; } randomId() { return Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10); } getCanvasFiles() { return this.plugin.app.vault.getFiles().filter((file) => file.extension === "canvas"); } updateStatusBar() { const file = this.selectedCanvas; if (file) { if (!this.statusBarItemEl) { this.statusBarItemEl = this.plugin.addStatusBarItem(); this.statusBarItemEl.addClass("scs-status-canvas"); this.plugin.registerDomEvent(this.statusBarItemEl, "click", this.handleStatusBarClick.bind(this)); } this.statusBarItemEl.empty(); this.statusBarItemEl.setText(`Selected Canvas: ${file.name}`); this.statusBarItemEl.title = `Click to clear selection: ${file.name}`; this.statusBarItemEl.addClass("is-active"); } else { this.clearStatusBar(); } } handleStatusBarClick(evt) { evt.preventDefault(); this.clearSelectedCanvas(); } clearSelectedCanvas(showNotice = true) { if (this.selectedCanvas) { const fileName = this.selectedCanvas.name; this.selectedCanvas = null; this.clearStatusBar(); if (showNotice) { new import_obsidian2.Notice(`Cleared selected Canvas: ${fileName}`); } } else if (showNotice) { new import_obsidian2.Notice("No Canvas file is currently selected."); } } clearStatusBar() { if (this.statusBarItemEl) { this.statusBarItemEl.remove(); this.statusBarItemEl = null; } } }; var CanvasFileSuggestModal = class extends import_obsidian2.FuzzySuggestModal { constructor(app, files, onSelect) { super(app); this.files = files; this.onSelect = onSelect; this.setPlaceholder("Select a Canvas file..."); } getItems() { return this.files; } getItemText(file) { return file.path; } onChooseItem(file, evt) { this.onSelect(file); this.close(); } }; // src/settings.ts var DEFAULT_SETTINGS = { showReleaseNotes: true, previousRelease: "0.0.0" }; // src/utils.ts function isVersionNewer(currentVersion, oldVersion) { if (!currentVersion || !oldVersion) return false; if (currentVersion === oldVersion) return false; const current = currentVersion.split(".").map(Number); const old = oldVersion.split(".").map(Number); for (let i = 0; i < 3; i++) { const v1 = current[i] || 0; const v2 = old[i] || 0; if (v1 > v2) return true; if (v1 < v2) return false; } return false; } // src/ReleaseNotesModal.ts var import_obsidian3 = require("obsidian"); // src/releaseNotesData.ts var firstInstallContent = ` Building upon the original "Property Link" and "Auto Focus" features, I am excited to share three key additions in recent updates. ### \u2728 Split Node by Headings **Split Node by Headings** instantly deconstructs a single file node into a hierarchical tree based on its headings. You can try this by right-clicking on a file node and selecting "Split Node by Headings" in Canvas. ### \u2728 Send Note to Canvas With the **"Send to Canvas"** plugin command, you can push your current markdown note directly to a specific Canvas. It automatically appends a "canvas" property to your note, allowing you to navigate back to the board instantly in the future. Once you have sent a note, that Canvas becomes the "Selected." You can then use the **"Send to Selected Canvas"** plugin command on other notes to instantly add them to the same board\u2014bypassing the file selection step entirely. ### \u2728 Auto-Resize Node This is a feature I\u2019ve wanted for a long time. Previously, double-clicking the bottom edge would fit a node to its content, but changing the width would break this fit, forcing you to double-click again. With this update, double-clicking the bottom edge activates "Auto-Resize." Now, the node's height dynamically adapts to fit your content\u2014whether you are **adjusting the width** or **updating the text**. No need for repeated double-clicking! [View detailed demo at github](https://github.com/RobertttBS/obsidian-enhanced-canvas) `; var releaseNotesContent = { "1.0.17": firstInstallContent }; // src/ReleaseNotesModal.ts var ReleaseNotesModal = class extends import_obsidian3.Modal { constructor(app, plugin, version, isNewInstall) { super(app); this.plugin = plugin; this.version = version; this.isNewInstall = isNewInstall; } onOpen() { const { contentEl, titleEl } = this; titleEl.setText( this.isNewInstall ? "Welcome to Enhanced Canvas" : `Enhanced Canvas updated to v${this.version}` ); contentEl.classList.add("enhanced-canvas-release-notes"); this.renderContent(); } async renderContent() { const { contentEl } = this; const markdownText = this.isNewInstall ? firstInstallContent : releaseNotesContent[this.version] || "Thank you for updating! This update includes bug fixes."; await import_obsidian3.MarkdownRenderer.render( this.app, markdownText, contentEl, "/", this.plugin ); const buttonContainer = contentEl.createDiv({ cls: "release-notes-button-container" }); buttonContainer.style.marginTop = "20px"; buttonContainer.style.textAlign = "right"; new import_obsidian3.ButtonComponent(buttonContainer).setButtonText("Got it").setCta().onClick(() => { this.close(); }); } async onClose() { this.contentEl.empty(); if (this.plugin.settings.previousRelease !== this.version) { this.plugin.settings.previousRelease = this.version; await this.plugin.saveSettings(); } } }; // main.ts var EnhancedCanvas = class extends import_obsidian4.Plugin { constructor() { super(...arguments); this.isMetadataClicked = false; this.autoHeightCheckReference = null; this.autoLinkCheckReference = null; /** * Modifies a file's frontmatter property to ensure a specific value is either * included or excluded while maintaining list integrity. */ this.updateFrontmatter = async (file, link, action, propertyName) => { await this.app.fileManager.processFrontMatter(file, (fm) => { const existingValue = Reflect.get(fm, propertyName); let currentSet = /* @__PURE__ */ new Set(); if (Array.isArray(existingValue)) { existingValue.filter((item) => typeof item === "string" && item.trim() !== "").forEach((item) => currentSet.add(item)); } else if (typeof existingValue === "string" && existingValue.trim() !== "") { currentSet.add(existingValue); } if (action === "add") { currentSet.add(link); } else if (action === "remove") { currentSet.delete(link); } const finalArray = Array.from(currentSet); if (finalArray.length > 0) { Reflect.set(fm, propertyName, finalArray); } else { Reflect.deleteProperty(fm, propertyName); } }); }; this.ifActiveViewIsCanvas = (commandFn) => (checking) => { const activeView = this.app.workspace.getActiveViewOfType(import_obsidian4.ItemView); if ((activeView == null ? void 0 : activeView.getViewType()) !== "canvas") { return checking ? false : void 0; } if (checking) return true; const canvas = activeView.canvas; const canvasData = canvas == null ? void 0 : canvas.getData(); if (!canvas || !canvasData) return; return commandFn(canvas, canvasData); }; } /** * Scans the selected nodes to identify underlying file references and automatically * generates visual connections where valid links exist between files but edges are * currently missing on the canvas. */ createMissingEdgesFromLinks(canvas) { const selectedNodes = Array.from(canvas.selection); const fileNodes = selectedNodes.filter((node) => node == null ? void 0 : node.filePath); const resolvedLinks = this.app.metadataCache.resolvedLinks; const currentData = canvas.getData(); const existingEdgesMap = /* @__PURE__ */ new Map(); currentData.edges.forEach((edge) => { existingEdgesMap.set(`${edge.fromNode}->${edge.toNode}`, edge); }); const filePathToNodeMap = /* @__PURE__ */ new Map(); fileNodes.forEach((node) => { if (node.filePath) { filePathToNodeMap.set(node.filePath, node); } }); const newEdges = []; fileNodes.forEach((sourceNode) => { const links = resolvedLinks[sourceNode.filePath]; if (!links) return; Object.keys(links).forEach((targetPath) => { const targetNode = filePathToNodeMap.get(targetPath); if (targetNode && targetNode !== sourceNode) { const edgeKey = `${sourceNode.id}->${targetNode.id}`; if (!existingEdgesMap.has(edgeKey)) { const newEdge = this.createEdge(sourceNode, targetNode); newEdges.push(newEdge); existingEdgesMap.set(edgeKey, newEdge); } } }); }); if (newEdges.length > 0) { currentData.edges.push(...newEdges); canvas.setData(currentData); canvas.requestSave(); } } /** * Recomputes the connection anchor points for all edges contained within the * current selection to ensure optimal visual alignment and routing. */ optimizeEdgesBetweenSelectedNodes(canvas) { const selectedNodes = Array.from(canvas.selection); if (selectedNodes.length < 2) return; const currentData = canvas.getData(); const selectedNodeIds = new Set(selectedNodes.map((node) => node.id)); let didUpdateEdges = false; currentData.edges.forEach((edge) => { if (selectedNodeIds.has(edge.fromNode) && selectedNodeIds.has(edge.toNode)) { const fromNode = currentData.nodes.find((node) => node.id === edge.fromNode); const toNode = currentData.nodes.find((node) => node.id === edge.toNode); if (fromNode && toNode) { const updatedEdge = this.createEdge(fromNode, toNode); if (edge.fromSide !== updatedEdge.fromSide || edge.toSide !== updatedEdge.toSide) { edge.fromSide = updatedEdge.fromSide; edge.toSide = updatedEdge.toSide; didUpdateEdges = true; } } } }); if (didUpdateEdges) { canvas.setData(currentData); canvas.requestSave(); } } deleteEdges(canvas) { const selectedNodes = Array.from(canvas.selection); const selectedNodeIds = new Set(selectedNodes.map((node) => node.id)); const currentData = canvas.getData(); currentData.edges = currentData.edges.filter((edge) => { return !(selectedNodeIds.has(edge.fromNode) && selectedNodeIds.has(edge.toNode)); }); canvas.setData(currentData); canvas.requestSave(); } /** * add 'canvas' and canvas basename properties to the node frontmatter. */ addProperty(node, propertyName, basename) { const file = this.app.vault.getFileByPath(node.file); if (!file) return; this.app.fileManager.processFrontMatter(file, (frontmatter) => { if (!frontmatter) return; if (!frontmatter.canvas) { frontmatter.canvas = []; } const canvasLink = `[[${propertyName}]]`; if (!frontmatter.canvas.includes(canvasLink)) { frontmatter.canvas.push(canvasLink); } if (!frontmatter[basename]) { frontmatter[basename] = []; } }); } /** * For JSON nodes only, which are stored in the canvas file, not the canvas node in Obsidian. */ removeProperty(node, propertyName, basename) { const file = this.app.vault.getFileByPath(node.file); if (!file) return; this.app.fileManager.processFrontMatter(file, (frontmatter) => { if (!frontmatter) return; if (frontmatter[basename]) { delete frontmatter[basename]; } if (frontmatter.canvas) { const canvasLink = `[[${propertyName}]]`; frontmatter.canvas = frontmatter.canvas.filter((link) => link !== canvasLink); if (frontmatter.canvas.length === 0) { delete frontmatter.canvas; } } }); } /** * For JSON nodes only, which are stored in the canvas file, not the canvas node in Obsidian. */ renameProperty(node, oldName, newName) { const file = this.app.vault.getFileByPath(node.file); if (!file) return; const getBaseName = (name) => name.substring(name.lastIndexOf("/") + 1); newName = getBaseName(newName); const oldBaseName = oldName.replace(".canvas", ""); const newBaseName = newName.replace(".canvas", ""); this.app.fileManager.processFrontMatter(file, (frontmatter) => { if (!frontmatter) return; const newFrontmatter = Object.fromEntries( Object.entries(frontmatter).map(([key, value]) => [ key === oldBaseName ? newBaseName : key, value ]) ); Object.keys(frontmatter).forEach((key) => { delete frontmatter[key]; }); Object.assign(frontmatter, newFrontmatter); }); } removeAllProperty(canvas, canvasData) { const nodes = canvasData.nodes; nodes.forEach((node) => { if (!(node == null ? void 0 : node.file)) return; this.removeProperty(node, canvas.view.file.name, canvas.view.file.basename); }); canvas.setData(canvasData); canvas.requestSave(); } async processEdgeUpdate(e) { var _a, _b; const fromNode = (_a = e == null ? void 0 : e.from) == null ? void 0 : _a.node; const toNode = (_b = e == null ? void 0 : e.to) == null ? void 0 : _b.node; if (!fromNode || !toNode) return; if (!(fromNode == null ? void 0 : fromNode.filePath) && !(fromNode == null ? void 0 : fromNode.file)) return; const fromFilePath = fromNode.filePath || fromNode.file; const toFilePath = toNode.filePath || toNode.file; const fromFile = this.app.vault.getFileByPath(fromFilePath); const toFile = this.app.vault.getFileByPath(toFilePath); if (fromFilePath === toFilePath) return; if (!fromFile || !toFile) return; const canvasName = e.canvas.view.file.basename; let link = this.app.fileManager.generateMarkdownLink(toFile, fromFilePath).replace(/^!(\[\[.*\]\])$/, "$1"); await this.updateFrontmatter(fromFile, link, "add", canvasName); } /** * Orchestrates a batch update for all connections within the provided canvas data * to ensure every edge is validated and processed according to the plugin's * current update logic. */ async processEdgesInCanvas(canvasData, canvasFile) { if (!canvasData) return; const tempCanvas = { view: { file: canvasFile }, getData: () => canvasData }; const nodeIdToNodeMap = /* @__PURE__ */ new Map(); if (canvasData.nodes && Array.isArray(canvasData.nodes)) { for (const node of canvasData.nodes) { nodeIdToNodeMap.set(node.id, node); } } if (canvasData.edges && Array.isArray(canvasData.edges)) { for (const edgeData of canvasData.edges) { const fromNode = nodeIdToNodeMap.get(edgeData.fromNode); const toNode = nodeIdToNodeMap.get(edgeData.toNode); if (!fromNode || !toNode) continue; const e = { from: { node: fromNode }, to: { node: toNode }, canvas: tempCanvas }; await this.processEdgeUpdate(e); } } } /** * Registers all core plugin features and performs an initial scan * of the vault to process and initialize data from all * existing canvas files upon loading. */ async onload() { await this.loadSettings(); this.checkReleaseNotes(); this.exploder = new CanvasExploder(this); this.sendToCanvas = new SendToCanvas(this); this.registerPluginCommands(); this.registerCanvasAutoLink(); this.registerFileManagerPatches(); this.registerFocusCanvas(); this.registerCanvasExploder(); this.registerCanvasNodeAutoHeightPatcher(); try { const canvasFiles = this.app.vault.getFiles().filter((file) => file.extension === "canvas"); await Promise.all(canvasFiles.map(async (canvasFile) => { try { const content = await this.app.vault.read(canvasFile); if (!content || content.trim() === "") return; try { const canvasData = JSON.parse(content); if (!canvasData) return; if (canvasData.nodes && Array.isArray(canvasData.nodes)) { for (const node of canvasData.nodes) { if (!(node == null ? void 0 : node.file)) continue; this.addProperty(node, canvasFile.name, canvasFile.basename); } } await this.processEdgesInCanvas(canvasData, canvasFile); } catch (parseError) { return; } } catch (fileError) { return; } })); } catch (error) { return; } } checkReleaseNotes() { try { const currentVersion = this.manifest.version; const previousVersion = this.settings.previousRelease; const isNewInstall = previousVersion === "0.0.0" || !previousVersion; if (this.settings.showReleaseNotes) { if (isNewInstall || isVersionNewer(currentVersion, previousVersion)) { new ReleaseNotesModal( this.app, this, currentVersion, isNewInstall ).open(); } } } catch (e) { console.error("Failed to show release notes:", e); } } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); } /** * Registers patches for the application's FileManager to intercept file deletion * and rename operations. This function ensures data integrity by automatically * cleaning up references to the modified files throughout the vault before the * original operation proceeds. */ registerFileManagerPatches() { const plugin = this; const deleteFile = async (file) => { if (file.deleted === true) return; const backLinks = plugin.app.metadataCache.getBacklinksForFile(file); if (!backLinks || !backLinks.data) return; const linkRegexBasename = new RegExp(`\\[\\[${file.basename}(\\|.*)?\\]\\]`); const linkRegexFullName = new RegExp(`\\[\\[${file.name}(\\|.*)?\\]\\]`); for (const [sourcePath, references] of backLinks.data.entries()) { const sourceFile = plugin.app.vault.getFileByPath(sourcePath); if (!sourceFile || sourceFile.extension !== "md") continue; await plugin.app.fileManager.processFrontMatter(sourceFile, (frontmatter) => { if (!frontmatter) return; Object.keys(frontmatter).forEach((key) => { if (Array.isArray(frontmatter[key])) { frontmatter[key] = frontmatter[key].filter((item) => { if (typeof item !== "string") return true; return !(linkRegexBasename.test(item) || linkRegexFullName.test(item)); }); } }); }); } }; const deleteCanvasFile = async (file) => { if (file.extension !== "canvas") return; if (file.deleted === true) return; const content = await plugin.app.vault.read(file); if (!content) return; const canvasData = JSON.parse(content); if (!canvasData) return; canvasData.nodes.forEach((node) => { if (node.type !== "file") return; plugin.removeProperty(node, file.name, file.basename); }); }; const renameCanvasFile = async (file, newPath) => { if (file.extension !== "canvas") return; if (file.deleted === true) return; const content = await plugin.app.vault.read(file); if (!content) return; const canvasData = JSON.parse(content); if (!canvasData) return; canvasData.nodes.forEach((node) => { if (node.type !== "file") return; plugin.renameProperty(node, file.name, newPath); }); }; const uninstaller = around(this.app.fileManager.constructor.prototype, { trashFile(old) { return function(file) { deleteCanvasFile(file); deleteFile(file); return old.call(this, file); }; }, renameFile(old) { return function(file, newPath) { renameCanvasFile(file, newPath); return old.call(this, file, newPath); }; } }); this.register(uninstaller); } /** * Registers all plugin commands, making them available in the * Obsidian command palette. * * All commands registered here are context-aware and will only be enabled * when the active view is a Canvas. */ registerPluginCommands() { this.addCommand({ id: "optimize-edges", name: "Adjust edges with shortest path", checkCallback: this.ifActiveViewIsCanvas((canvas, canvasData) => { this.optimizeEdgesBetweenSelectedNodes(canvas); }) }); this.addCommand({ id: "delete-edges", name: "Delete edges between selected nodes", checkCallback: this.ifActiveViewIsCanvas((canvas, canvasData) => { this.deleteEdges(canvas); }) }); this.addCommand({ id: "add-link-and-optimize-edge", name: "Add edges according the links in notes", checkCallback: this.ifActiveViewIsCanvas((canvas, canvasData) => { this.createMissingEdgesFromLinks(canvas); this.optimizeEdgesBetweenSelectedNodes(canvas); }) }); this.addCommand({ id: "remove-canvas-property", name: "Remove the property of all nodes in current Canvas", checkCallback: this.ifActiveViewIsCanvas((canvas, canvasData) => { this.removeAllProperty(canvas, canvasData); }) }); this.addCommand({ id: "send-to-canvas", name: "Send to Canvas", callback: () => { this.sendToCanvas.handleSendToCanvas(); } }); this.addCommand({ id: "send-to-selected-canvas", name: "Send to Selected Canvas", callback: () => { this.sendToCanvas.handleSendToSelectedCanvas(); } }); this.addCommand({ id: "clear-selected-canvas-file", name: "Clear selected Canvas file", callback: () => { this.sendToCanvas.clearSelectedCanvas(); } }); } /** * Registers event listeners to implement a "zoom to node" feature. * * This function's goal is to automatically focus the canvas on the relevant * node when a user navigates to the canvas by clicking a link from another * file's metadata/properties panel (e.g., a backlink). */ registerFocusCanvas() { this.registerDomEvent(document, "click", (evt) => { const target = evt.target; if (target.closest(".metadata-container")) { this.isMetadataClicked = true; setTimeout(() => { this.isMetadataClicked = false; }, 500); } }, true); this.registerEvent( this.app.workspace.on("active-leaf-change", () => { Promise.resolve().then(async () => { if (this.isMetadataClicked == false) return; const activeLeaf = this.app.workspace.getActiveViewOfType(import_obsidian4.ItemView); if (!activeLeaf || activeLeaf.getViewType() !== "canvas") return; const prevFile = this.app.workspace.getLastOpenFiles()[0]; if (!prevFile) return; const canvas = await activeLeaf.canvas; if (!canvas) return; for (const [key, value] of canvas.nodes) { if ((value == null ? void 0 : value.filePath) === prevFile) { canvas.select(value); } } setTimeout(() => { canvas.zoomToSelection(); }, 100); }); }) ); } /** * Establishes real-time synchronization to manage frontmatter properties * in source files based on their connections within a canvas. * This ensures file metadata automatically reflects the * visual graph structure as nodes and edges are added, removed, or updated. */ registerCanvasAutoLink() { const plugin = this; const processNodeUpdate = async (e) => { var _a, _b; const fromNode = (_a = e == null ? void 0 : e.from) == null ? void 0 : _a.node; const toNode = (_b = e == null ? void 0 : e.to) == null ? void 0 : _b.node; if (!fromNode || !toNode) return; if (!(fromNode == null ? void 0 : fromNode.filePath)) return; const fromFile = this.app.vault.getFileByPath(fromNode.filePath); if (!fromFile) return; const canvasName = await e.canvas.view.file.basename; const resolvedLinks = this.app.metadataCache.resolvedLinks[fromNode.filePath] || {}; const fromNodeLinks = Object.keys(resolvedLinks); const { edges, nodes } = await e.canvas.getData(); const sameFileNodes = nodes.filter((node) => node.file === fromNode.filePath); const allRelevantEdges = edges.filter( (edge) => sameFileNodes.some((node) => edge.fromNode === node.id) ); const edgeToNodesFilePathSet = new Set( allRelevantEdges.map((edge) => nodes.find((node) => node.id === edge.toNode)).filter((node) => node && node.file).map((node) => node.file) ); const updatePromises = []; const getFilePath = (path) => this.app.vault.getFileByPath(path); fromNodeLinks.forEach((filePath) => { if (!edgeToNodesFilePathSet.has(filePath)) { if (filePath === e.canvas.view.file.path) return; const targetFile = getFilePath(filePath); if (!targetFile) return; let link = this.app.fileManager.generateMarkdownLink(targetFile, filePath).replace(/^!(\[\[.*\]\])$/, "$1"); updatePromises.push(this.updateFrontmatter(fromFile, link, "remove", canvasName)); } }); if (toNode == null ? void 0 : toNode.filePath) { if (fromNode.filePath !== toNode.filePath) { const targetFile = getFilePath(toNode.filePath); if (targetFile) { let link = this.app.fileManager.generateMarkdownLink(targetFile, toNode.filePath).replace(/^!(\[\[.*\]\])$/, "$1"); updatePromises.push(this.updateFrontmatter(fromFile, link, "add", canvasName)); } } } await Promise.all(updatePromises); }; const updateTargetNode = (0, import_obsidian4.debounce)(async (e) => { await processNodeUpdate(e); }, 500, true); const updateTargetNodeImmediate = async (e) => { await processNodeUpdate(e); }; const updateOriginalNode = async (edge) => { var _a, _b; if (!((_a = edge.to.node) == null ? void 0 : _a.filePath) || !((_b = edge.from.node) == null ? void 0 : _b.filePath)) return; const canvasName = edge.canvas.view.file.basename; const toNode = edge.to.node; const fromNode = edge.from.node; const file = this.app.vault.getFileByPath(toNode.filePath); if (!file) return; let link = this.app.fileManager.generateMarkdownLink(file, toNode.filePath); link = link.replace(/^!(\[\[.*\]\])$/, "$1"); if (fromNode == null ? void 0 : fromNode.filePath) { const fromFile = this.app.vault.getFileByPath(fromNode.filePath); if (!fromFile) return; const { edges, nodes } = await edge.canvas.getData(); const sameFileNodes = nodes.filter((node) => node.file === fromNode.filePath); const stillHasConnection = edges.some( (e) => sameFileNodes.some((node) => e.fromNode === node.id) && e.toNode === toNode.id && !(e.fromNode === fromNode.id && e.toNode === toNode.id) ); if (!stillHasConnection) { this.updateFrontmatter(fromFile, link, "remove", canvasName); } } }; const removeNodeUpdate = async (node) => { var _a, _b, _c; const resolvedNode = await node; if (((_a = resolvedNode == null ? void 0 : resolvedNode.file) == null ? void 0 : _a.extension) !== "md") return; const canvasFile = (_c = (_b = resolvedNode == null ? void 0 : resolvedNode.canvas) == null ? void 0 : _b.view) == null ? void 0 : _c.file; if (!canvasFile || !canvasFile.name) return; if (resolvedNode == null ? void 0 : resolvedNode.filePath) { const canvasData = await resolvedNode.canvas.getData(); const otherNodes = canvasData.nodes.filter( (n) => { return n.file === resolvedNode.filePath; } ); if (otherNodes.length === 0) { let tmpNode = {}; tmpNode.file = resolvedNode.filePath; this.removeProperty(tmpNode, canvasFile.name, canvasFile.basename); } } }; const addNodeUpdate = async (node) => { var _a; const resolvedNode = await node; if (((_a = resolvedNode == null ? void 0 : resolvedNode.file) == null ? void 0 : _a.extension) !== "md") return; const canvasFile = resolvedNode.canvas.view.file; if (!canvasFile || !canvasFile.name) return; if (resolvedNode.filePath) { let tmpNode = {}; tmpNode.file = resolvedNode.filePath; this.addProperty(tmpNode, canvasFile.name, canvasFile.basename); } }; const selfPatched = (edge) => { this.patchedEdge = true; const uninstaller = around(edge.constructor.prototype, { update: (next) => { return function(...args) { const result = next.call(this, ...args); updateTargetNode(this); return result; }; } }); plugin.register(uninstaller); }; const patchCanvas = () => { var _a; const canvasView = (_a = plugin.app.workspace.getLeavesOfType("canvas")[0]) == null ? void 0 : _a.view; if (!(canvasView == null ? void 0 : canvasView.canvas)) return false; const uninstaller = around(canvasView.canvas.constructor.prototype, { removeNode(old) { return function(node) { const result = old.call(this, node); if (this.isClearing !== true) { removeNodeUpdate(node); } return result; }; }, addNode(old) { return function(node) { const result = old.call(this, node); addNodeUpdate(node); return result; }; }, removeEdge(old) { return function(edge) { const result = old.call(this, edge); if (this.isClearing !== true) { updateOriginalNode(edge); } return result; }; }, addEdge(old) { return function(edge) { const result = old.call(this, edge); if (!plugin.patchedEdge) { plugin.patchedEdge = true; selfPatched(edge); } updateTargetNodeImmediate(edge); return result; }; }, clear(old) { return function() { this.isClearing = true; const result = old.call(this); this.isClearing = false; return result; }; } }); plugin.register(uninstaller); return true; }; const tryToPatch = () => { if (patchCanvas()) { plugin.detachAutoLinkListeners(); } }; plugin.autoLinkCheckReference = tryToPatch; plugin.app.workspace.on("active-leaf-change", tryToPatch); plugin.app.workspace.on("layout-change", tryToPatch); tryToPatch(); } registerCanvasExploder() { this.registerEvent( this.app.workspace.on("file-menu", (menu) => { this.exploder.checkAndAddMenu(menu, "Split by Headings"); }) ); this.registerEvent( this.app.workspace.on("editor-menu", (menu) => { this.exploder.checkAndAddMenu(menu, "Split by Headings"); }) ); } /** * Installs hooks into the native Canvas prototype to enable automatic height adjustment behavior, * allowing nodes to dynamically resize to fit their content in response to specific user gestures * on resize handles. */ patchCanvasNodeAutoHeight() { var _a, _b; const canvasView = (_b = (_a = this.app.workspace.getLeavesOfType("canvas")) == null ? void 0 : _a.first()) == null ? void 0 : _b.view; if (!canvasView) return false; const canvas = canvasView.canvas; if (!canvas) return false; const anyNode = canvas.nodes.values().next().value; if (!anyNode) return false; const anyNodeConstructor = anyNode.constructor; const baseNodePrototype = Object.getPrototypeOf(anyNodeConstructor.prototype); const methodsToPatch = {}; if (baseNodePrototype.onResizeDblclick) { methodsToPatch.onResizeDblclick = (originalMethod) => { return function(...args) { const [, direction] = args; if (direction === "bottom") { if (this._autoHeightTimer) { window.clearTimeout(this._autoHeightTimer); this._autoHeightTimer = null; } this.autoHeightEnabled = true; } return originalMethod.apply(this, args); }; }; } if (baseNodePrototype.onResizePointerdown) { methodsToPatch.onResizePointerdown = (originalMethod) => { return function(...args) { const [event, direction] = args; const result = originalMethod.apply(this, args); if (direction === "bottom") { if (this._autoHeightTimer) { window.clearTimeout(this._autoHeightTimer); this._autoHeightTimer = null; } else { this._autoHeightTimer = window.setTimeout(() => { this.autoHeightEnabled = false; this._autoHeightTimer = null; }, 250); } } else if (direction === "right" || direction === "left") { if (this.autoHeightEnabled === true) { const handlePointerUp = () => { window.setTimeout(() => { if (!this.canvas || !this.canvas.nodes.has(this.id)) return; if (this.nodeEl && this.nodeEl.classList.contains("is-resizing")) { return; } this.onResizeDblclick(event, "bottom"); }, 0); }; window.addEventListener("pointerup", handlePointerUp, { once: true }); } } return result; }; }; } if (baseNodePrototype.blur) { methodsToPatch.blur = (originalMethod) => { return function(...args) { const result = originalMethod.apply(this, args); if (this.autoHeightEnabled) { setTimeout(() => { if (typeof this.onResizeDblclick === "function") { const mockEvent = { preventDefault: () => { }, stopPropagation: () => { } }; this.onResizeDblclick(mockEvent, "bottom"); } }, 300); } return result; }; }; } if (Object.keys(methodsToPatch).length === 0) return false; this.uninstaller = around(baseNodePrototype, methodsToPatch); this.register(this.uninstaller); return true; } registerCanvasNodeAutoHeightPatcher() { const tryToPatch = () => { const success = this.patchCanvasNodeAutoHeight(); if (success) { this.detachAutoHeightPatcherListeners(); } }; this.autoHeightCheckReference = tryToPatch; this.app.workspace.on("active-leaf-change", tryToPatch); this.app.workspace.on("layout-change", tryToPatch); tryToPatch(); } detachAutoHeightPatcherListeners() { if (this.autoHeightCheckReference) { this.app.workspace.off("active-leaf-change", this.autoHeightCheckReference); this.app.workspace.off("layout-change", this.autoHeightCheckReference); this.autoHeightCheckReference = null; } } detachAutoLinkListeners() { if (this.autoLinkCheckReference) { this.app.workspace.off("active-leaf-change", this.autoLinkCheckReference); this.app.workspace.off("layout-change", this.autoLinkCheckReference); this.autoLinkCheckReference = null; } } createEdge(node1, node2) { const random = (e) => { let t = []; for (let n = 0; n < e; n++) { t.push((16 * Math.random() | 0).toString(16)); } return t.join(""); }; const node1CenterX = node1.x + node1.width / 2; const node1CenterY = node1.y + node1.height / 2; const node2CenterX = node2.x + node2.width / 2; const node2CenterY = node2.y + node2.height / 2; const angle = Math.atan2(node2CenterY - node1CenterY, node2CenterX - node1CenterX) * 180 / Math.PI; const normalizedAngle = angle < 0 ? angle + 360 : angle; let fromSide; let toSide; if (normalizedAngle >= 315 || normalizedAngle < 45) { fromSide = "right"; toSide = "left"; } else if (normalizedAngle >= 45 && normalizedAngle < 135) { fromSide = "bottom"; toSide = "top"; } else if (normalizedAngle >= 135 && normalizedAngle < 225) { fromSide = "left"; toSide = "right"; } else { fromSide = "top"; toSide = "bottom"; } const edgeData = { id: random(16), fromSide, fromNode: node1.id, toSide, toNode: node2.id }; return edgeData; } /** * Performs a comprehensive cleanup on all canvas files when the plugin is * unloaded, ensuring any custom properties or data managed by this plugin * are removed from the vault. */ async onunload() { this.detachAutoHeightPatcherListeners(); this.detachAutoLinkListeners(); this.sendToCanvas.clearSelectedCanvas(false); try { const canvasFiles = this.app.vault.getFiles().filter((file) => file.extension === "canvas"); await Promise.all(canvasFiles.map(async (canvasFile) => { try { const content = await this.app.vault.read(canvasFile); const canvasData = JSON.parse(content); const tempCanvas = { view: { file: canvasFile }, setData: () => { }, requestSave: () => { } }; this.removeAllProperty(tempCanvas, canvasData); } catch (error) { return; } })); } catch (error) { return; } } }; /* nosourcemap */