Files
obsidian_vault/.obsidian/plugins/enhanced-canvas/main.js
T
2025-12-27 11:44:50 +08:00

1431 lines
50 KiB
JavaScript

/*
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 */