1431 lines
50 KiB
JavaScript
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 */ |