robertttbs_obsidian-enhance.../main.ts
RobertttBS db8cb24a00 refactor: cut ~380 lines of over-engineering across the plugin
- AdvancedTagSuggestModal: replace hand-rolled suggestion dropdown with
  Obsidian's AbstractInputSuggest (word-at-cursor completion preserved);
  drop its orphaned CSS from styles.css
- Canvas.d.ts: prune reverse-engineered typings to members actually used
- main.ts: remove dead awaits in node hooks, redundant clearInterval,
  Reflect.* indirection; reuse frontmatterValueToArray; inline one-line
  wrappers
- utils.ts: simplify isVersionNewer to a segment-aware numeric loop
- SendToCanvas: one-expression startY calculation
- delete version-bump.mjs + npm version script (unused; manual release
  flow documented in CLAUDE.md); AGENTS.md now points at CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:21:50 +08:00

1581 lines
53 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
App,
FileManager,
FileView,
Menu,
debounce,
ItemView,
Plugin,
PluginSettingTab,
Setting,
TFile,
Notice
} from 'obsidian';
import { CanvasEdgeData, CanvasNodeData, NodeSide, CanvasData } from "obsidian/canvas";
import { around } from "monkey-around";
import { Canvas, CanvasEdge, CanvasNode, CanvasView, Position, Size } from 'Canvas';
import { CanvasExploder } from './src/CanvasExploder';
import { SendToCanvas } from './src/SendToCanvas';
import { CanvasTagImport } from './src/CanvasTagImport';
import { EnhancedCanvasSettings, DEFAULT_SETTINGS } from "./src/settings";
import { isVersionNewer, randomId } from "./src/utils";
import { ReleaseNotesModal } from "./src/ReleaseNotesModal";
interface CanvasNodeWithFlag extends CanvasNode {
_autoHeightTimer?: number | null;
autoHeightEnabled?: boolean;
onResizeDblclick(event: unknown, direction: string): void;
}
/** A JSON canvas node, narrowed to just the file reference used by the
* property-mutating methods. Accepts both AllCanvasNodeData (via its
* `[key: string]: any` index signature) and ad-hoc `{ file: '…' }` stubs. */
interface JsonNodeRef {
file?: string;
[key: string]: unknown;
}
/** Normalizes a frontmatter value (missing, string, or array) to a string array. */
function frontmatterValueToArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string' && item.trim() !== '');
}
if (typeof value === 'string' && value.trim() !== '') return [value];
return [];
}
/**
* Reads a frontmatter link property as an array, applies `mutate`, and writes the
* result back — preserving scalar-vs-list shape and deleting the key when empty.
*/
function writeLinkSet(fm: Record<string, unknown>, key: string, mutate: (links: string[]) => string[]) {
const existingValue = fm[key];
const wasString = typeof existingValue === 'string' && existingValue.trim() !== '';
const next = mutate(frontmatterValueToArray(existingValue));
if (next.length > 0) {
fm[key] = next.length === 1 && wasString ? next[0] : next;
} else {
delete fm[key];
}
}
/** Escapes regex metacharacters so file names can be embedded in a RegExp. */
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* processFrontMatter serializes keys in object insertion order, so a per-canvas
* property created before the `canvas` property (e.g. by an edge sync racing the
* node-add sync) ends up above it in the YAML. Rebuilds the object so `canvas`
* sits just before the first per-canvas key, leaving other properties in place.
*/
function ensureCanvasKeyOrder(frontmatter: Record<string, unknown>, canvasKeys: Iterable<string>) {
const keys = Object.keys(frontmatter);
const canvasIndex = keys.indexOf('canvas');
if (canvasIndex === -1) return;
const targetKeys = new Set(canvasKeys);
targetKeys.delete('canvas');
const firstTargetIndex = keys.findIndex(key => targetKeys.has(key));
if (firstTargetIndex === -1 || canvasIndex < firstTargetIndex) return;
const values = { ...frontmatter };
const reordered = keys.filter(key => key !== 'canvas');
reordered.splice(reordered.indexOf(keys[firstTargetIndex]), 0, 'canvas');
for (const key of keys) delete frontmatter[key];
for (const key of reordered) frontmatter[key] = values[key];
}
export default class EnhancedCanvas extends Plugin {
public exploder: CanvasExploder;
public sendToCanvas: SendToCanvas;
public canvasTagImport: CanvasTagImport;
private isMetadataClicked = false;
settings: EnhancedCanvasSettings;
public canvasStackInterval: number | null = null;
public autoHeightUninstaller: (() => void) | null = null;
public dragTempNodeUninstaller: (() => void) | null = null;
/**
* 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: Canvas) {
const selectedNodes = Array.from(canvas.selection) as CanvasNode[];
const fileNodes = selectedNodes.filter((node) => !!node?.filePath);
const resolvedLinks = this.app.metadataCache.resolvedLinks;
const currentData = canvas.getData();
const existingEdgesMap = new Map<string, CanvasEdgeData>();
currentData.edges.forEach((edge: CanvasEdgeData) => {
existingEdgesMap.set(`${edge.fromNode}->${edge.toNode}`, edge);
});
const filePathToNodeMap = new Map<string, CanvasNode>();
fileNodes.forEach((node: CanvasNode) => {
if (node.filePath) {
filePathToNodeMap.set(node.filePath, node);
}
});
const newEdges: CanvasEdgeData[] = [];
fileNodes.forEach((sourceNode: CanvasNode) => {
if (!sourceNode.filePath) return;
const links = resolvedLinks[sourceNode.filePath];
if (!links) return;
Object.keys(links).forEach((targetPath: string) => {
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(false);
}
}
/**
* Recomputes the connection anchor points for all edges contained within the
* current selection to ensure optimal visual alignment and routing.
*/
optimizeEdgesBetweenSelectedNodes(canvas: Canvas) {
const selectedNodes = Array.from(canvas.selection) as CanvasNode[];
if (selectedNodes.length < 2) return;
const currentData = canvas.getData();
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
const nodeById = new Map<string, CanvasNodeData>();
for (const node of currentData.nodes) {
nodeById.set(node.id, node);
}
let didUpdateEdges = false;
currentData.edges.forEach((edge: CanvasEdgeData) => {
if (selectedNodeIds.has(edge.fromNode) && selectedNodeIds.has(edge.toNode)) {
const fromNode = nodeById.get(edge.fromNode);
const toNode = nodeById.get(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(false);
}
}
deleteEdges(canvas: Canvas) {
const selectedNodes = Array.from(canvas.selection) as CanvasNode[];
const selectedNodeIds = new Set(selectedNodes.map((node) => node.id));
const currentData = canvas.getData();
currentData.edges = currentData.edges.filter((edge: CanvasEdgeData) => {
return !(selectedNodeIds.has(edge.fromNode) && selectedNodeIds.has(edge.toNode));
});
canvas.setData(currentData);
canvas.requestSave(false);
}
/**
* add 'canvas' and canvas basename properties to the node frontmatter.
*/
addProperty(node: JsonNodeRef, propertyName: string, basename: string) {
if (!this.settings.enableFrontmatter) return;
if (!node.file) return;
const file = this.app.vault.getFileByPath(node.file); // node is JSON node, not canvas node
if (!file) return;
const canvasLink = `[[${propertyName}]]`;
// Skip the write when the cached frontmatter already carries both properties.
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (cached && cached[basename] && frontmatterValueToArray(cached.canvas).includes(canvasLink)) return;
void this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (!frontmatter) return;
if (!frontmatter.canvas) {
frontmatter.canvas = [];
}
if (!frontmatter.canvas.includes(canvasLink)) {
frontmatter.canvas.push(canvasLink);
}
if (!frontmatter[basename]) {
frontmatter[basename] = [];
}
ensureCanvasKeyOrder(frontmatter, [basename]);
});
}
/**
* For JSON nodes only, which are stored in the canvas file, not the canvas node in Obsidian.
*/
removeProperty(node: JsonNodeRef, propertyName: string, basename: string) {
if (!this.settings.enableFrontmatter) return;
if (!node.file) return;
const file = this.app.vault.getFileByPath(node.file); // node is JSON node, not canvas node
if (!file) return;
// Skip the write when the cached frontmatter has nothing to remove.
const canvasLink = `[[${propertyName}]]`;
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (!cached || (!(basename in cached) && !frontmatterValueToArray(cached.canvas).includes(canvasLink))) return;
return this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (!frontmatter) return;
// remove the property
if (frontmatter[basename]) {
delete frontmatter[basename];
}
// remove the link from the canvas property
if (frontmatter.canvas) {
frontmatter.canvas = frontmatter.canvas.filter((link: string) => 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: JsonNodeRef, oldName: string, newName: string) {
if (!this.settings.enableFrontmatter) return;
if (!node.file) return;
const file = this.app.vault.getFileByPath(node.file);
if (!file) return;
const getBaseName = (name: string) => name.substring(name.lastIndexOf('/') + 1);
newName = getBaseName(newName);
const oldBaseName = oldName.endsWith('.canvas') ? oldName.slice(0, -7) : oldName;
const newBaseName = newName.endsWith('.canvas') ? newName.slice(0, -7) : newName;
void this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (!frontmatter) return;
// rebuild the frontmatter with the new property name
const newFrontmatter = Object.fromEntries(
Object.entries(frontmatter).map(([key, value]) => [
key === oldBaseName ? newBaseName : key,
value
])
);
// remove all properties and assign the new frontmatter
Object.keys(frontmatter).forEach(key => {
delete frontmatter[key];
});
Object.assign(frontmatter, newFrontmatter);
});
}
async removeAllProperty(canvas: Canvas, canvasData: CanvasData) {
if (!this.settings.enableFrontmatter) return;
const nodes = canvasData.nodes;
await Promise.all(nodes.map(node => {
if (!node?.file) return;
return this.removeProperty(node, canvas.view.file.name, canvas.view.file.basename);
}));
}
/**
* Startup sweep: ensures every markdown note referenced by a canvas carries
* the plugin's frontmatter properties (the `canvas` link list plus one
* property per canvas holding links to edge-connected notes).
*
* The desired state is aggregated across all canvases first, then compared
* against the metadata cache, so each note gets at most one
* `processFrontMatter` write — and none at all when it is already up to
* date, which is the common case on restart.
*/
async syncAllCanvasProperties() {
if (!this.settings.enableFrontmatter) return;
interface DesiredProps {
/** Entries the `canvas` property must contain, e.g. '[[Name.canvas]]'. */
canvasLinks: Set<string>;
/** Per-canvas properties (canvas basenames) that must exist. */
ensureKeys: Set<string>;
/** Links each per-canvas property must contain. */
linksByKey: Map<string, Set<string>>;
}
const desiredByPath = new Map<string, DesiredProps>();
const getDesired = (path: string): DesiredProps => {
let desired = desiredByPath.get(path);
if (!desired) {
desired = { canvasLinks: new Set(), ensureKeys: new Set(), linksByKey: new Map() };
desiredByPath.set(path, desired);
}
return desired;
};
const canvasFiles = this.app.vault.getFiles().filter(file => file.extension === 'canvas');
await Promise.all(canvasFiles.map(async (canvasFile) => {
let canvasData: CanvasData;
try {
const content = await this.app.vault.read(canvasFile);
if (!content || content.trim() === '') return;
canvasData = JSON.parse(content) as CanvasData;
} catch (error) {
console.error("Enhanced Canvas: Failed to read canvas file", canvasFile.path, error);
return;
}
if (!canvasData) return;
const nodes = Array.isArray(canvasData.nodes) ? canvasData.nodes : [];
const edges = Array.isArray(canvasData.edges) ? canvasData.edges : [];
const nodeById = new Map<string, CanvasNodeData>();
for (const node of nodes) {
nodeById.set(node.id, node);
}
for (const node of nodes) {
if (!node?.file) continue;
const desired = getDesired(node.file);
desired.canvasLinks.add(`[[${canvasFile.name}]]`);
desired.ensureKeys.add(canvasFile.basename);
}
for (const edgeData of edges) {
const fromNode = nodeById.get(edgeData.fromNode);
const toNode = nodeById.get(edgeData.toNode);
if (!fromNode?.file || !toNode?.file) continue;
if (fromNode.file === toNode.file) continue;
const toFile = this.app.vault.getFileByPath(toNode.file);
if (!toFile) continue;
const link = this.app.fileManager.generateMarkdownLink(toFile, fromNode.file).replace(/^!(\[\[.*\]\])$/, '$1');
const desired = getDesired(fromNode.file);
let links = desired.linksByKey.get(canvasFile.basename);
if (!links) {
links = new Set();
desired.linksByKey.set(canvasFile.basename, links);
}
links.add(link);
}
}));
await Promise.all(Array.from(desiredByPath.entries()).map(async ([path, desired]) => {
const file = this.app.vault.getFileByPath(path);
// processFrontMatter only works on markdown notes; skip everything else.
if (!file || file.extension !== 'md') return;
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (cached) {
const cachedCanvas = frontmatterValueToArray(cached.canvas);
const cachedKeys = Object.keys(cached);
const canvasKeyIndex = cachedKeys.indexOf('canvas');
const upToDate =
Array.from(desired.canvasLinks).every(link => cachedCanvas.includes(link)) &&
// The key must exist *and* sit below the `canvas` property in the YAML.
Array.from(desired.ensureKeys).every(key =>
!!cached[key] && canvasKeyIndex !== -1 && canvasKeyIndex < cachedKeys.indexOf(key)) &&
Array.from(desired.linksByKey.entries()).every(([key, links]) => {
const existing = frontmatterValueToArray(cached[key]);
return Array.from(links).every(link => existing.includes(link));
});
if (upToDate) return;
}
try {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (!frontmatter) return;
const canvasLinks = frontmatterValueToArray(frontmatter.canvas);
for (const link of desired.canvasLinks) {
if (!canvasLinks.includes(link)) {
canvasLinks.push(link);
}
}
frontmatter.canvas = canvasLinks;
for (const key of desired.ensureKeys) {
if (!frontmatter[key]) {
frontmatter[key] = [];
}
}
for (const [key, links] of desired.linksByKey) {
writeLinkSet(frontmatter, key, (merged) => {
for (const link of links) {
if (!merged.includes(link)) {
merged.push(link);
}
}
return merged;
});
}
ensureCanvasKeyOrder(frontmatter, desired.ensureKeys);
});
} catch (error) {
console.error("Enhanced Canvas: Failed to update properties for note", path, error);
}
}));
}
/**
* Modifies a file's frontmatter property to ensure a specific value is either
* included or excluded while maintaining list integrity.
*/
updateFrontmatter = async (file: TFile, link: string, action: 'add' | 'remove', propertyName: string) => {
if (!this.settings.enableFrontmatter) return;
await this.app.fileManager.processFrontMatter(file, (fm) => {
writeLinkSet(fm, propertyName, (links) => {
const set = new Set(links);
if (action === 'add') set.add(link);
else set.delete(link);
return Array.from(set);
});
ensureCanvasKeyOrder(fm, [propertyName]);
});
};
private ifActiveViewIsCanvas = (commandFn: (canvas: Canvas) => void) => (checking: boolean) => {
const activeView = this.app.workspace.getActiveViewOfType(ItemView);
if (activeView?.getViewType() !== 'canvas') {
return checking ? false : undefined;
}
if (checking) return true;
const canvas = (activeView as CanvasView).canvas;
if (!canvas) return;
return commandFn(canvas);
}
/** Applies or removes the body CSS class that gates the optional visual styles. */
toggleCSSClass(enabled: boolean) {
if (enabled) {
activeDocument.body.classList.add('enhanced-canvas-enabled');
} else {
activeDocument.body.classList.remove('enhanced-canvas-enabled');
}
}
/**
* Patching Canvas internals needs a live Canvas leaf, which may not exist
* at load time. Runs `patch` now and again on every workspace change that
* could introduce one, until it reports success — then detaches the retry
* listeners so the patch can't run twice (see CLAUDE.md). The listeners
* are also registered for unload cleanup, so a patch that never succeeds
* leaks nothing.
*/
registerLazyPatcher(patch: () => boolean) {
let patched = false;
const tryToPatch = () => {
if (patched || !patch()) return;
patched = true;
this.app.workspace.offref(leafEvent);
this.app.workspace.offref(layoutEvent);
};
const leafEvent = this.app.workspace.on('active-leaf-change', tryToPatch);
const layoutEvent = this.app.workspace.on('layout-change', tryToPatch);
this.registerEvent(leafEvent);
this.registerEvent(layoutEvent);
this.app.workspace.onLayoutReady(tryToPatch);
tryToPatch();
}
/**
* 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.canvasTagImport = new CanvasTagImport(this);
this.addSettingTab(new EnhancedCanvasSettingTab(this.app, this));
this.toggleCSSClass(this.settings.enableCustomCSS);
this.registerPluginCommands();
this.registerCanvasAutoLink();
this.registerFileManagerPatches();
this.registerFocusCanvas();
this.registerCanvasExploder();
this.canvasTagImport.register();
this.registerCanvasNodeAutoHeightPatcher();
this.registerCanvasDefaultNodeSize();
this.registerCanvasDragTempNodePatcher();
try {
await this.syncAllCanvasProperties();
} catch (error) {
console.error("Enhanced Canvas: Error in metadata update loop", error);
return;
}
/**
* Configures a monitoring routine to force Canvas views to recalculate their layout
* during "Stacked Tabs" sliding transitions, ensuring proper rendering when
* standard resize triggers are insufficient.
*/
this.registerEvent(
this.app.workspace.on('active-leaf-change', (leaf) => {
if (this.canvasStackInterval) {
window.clearInterval(this.canvasStackInterval);
this.canvasStackInterval = null;
}
if (!leaf) return;
const isStacked = !!activeDocument.querySelector('.workspace-tabs.mod-stacked');
if (!isStacked) return;
const view = leaf.view;
if (view.getViewType() !== 'canvas') return;
if (typeof view.onResize === 'function') {
view.onResize();
}
window.setTimeout(() => {
if (typeof view.onResize === 'function') view.onResize();
}, 200);
let lastLeft = view.containerEl.getBoundingClientRect().left;
this.canvasStackInterval = window.setInterval(() => {
if (!view || !view.containerEl) {
if (this.canvasStackInterval !== null) {
window.clearInterval(this.canvasStackInterval);
this.canvasStackInterval = null;
}
return;
}
const rect = view.containerEl.getBoundingClientRect();
if (Math.abs(rect.left - lastLeft) > 2) {
if (typeof view.onResize === 'function') {
view.onResize();
}
lastLeft = rect.left;
}
}, 200);
this.registerInterval(this.canvasStackInterval);
})
);
}
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,
previousVersion
).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 deleteFile = async (file: TFile) => {
if ((file as TFile & { deleted?: boolean }).deleted === true) return;
const backLinks = this.app.metadataCache.getBacklinksForFile(file);
if (!backLinks || !backLinks.data) return;
const linkRegexBasename = new RegExp(`\\[\\[${escapeRegExp(file.basename)}(\\|.*)?\\]\\]`);
const linkRegexFullName = new RegExp(`\\[\\[${escapeRegExp(file.name)}(\\|.*)?\\]\\]`);
for (const [sourcePath] of backLinks.data.entries()) {
const sourceFile = this.app.vault.getFileByPath(sourcePath);
if (!sourceFile || sourceFile.extension !== 'md') continue;
await this.app.fileManager.processFrontMatter(sourceFile, (frontmatter) => {
if (!frontmatter) return;
Object.keys(frontmatter).forEach(key => {
if (Array.isArray(frontmatter[key])) {
frontmatter[key] = frontmatter[key].filter((item: unknown) => {
if (typeof item !== 'string') return true;
return !(linkRegexBasename.test(item) || linkRegexFullName.test(item));
});
}
});
});
}
}
const deleteCanvasFile = async (file: TFile) => {
if (file.extension !== 'canvas') return;
if ((file as TFile & { deleted?: boolean }).deleted === true) return;
const content = await this.app.vault.read(file);
if (!content) return;
const canvasData = JSON.parse(content) as CanvasData;
if (!canvasData) return;
canvasData.nodes.forEach((node: CanvasNodeData) => {
if (node.type !== 'file') return;
void this.removeProperty(node, file.name, file.basename);
});
}
const renameCanvasFile = async (file: TFile, newPath: string) => {
if (file.extension !== 'canvas') return;
if ((file as TFile & { deleted?: boolean }).deleted === true) return;
const content = await this.app.vault.read(file);
if (!content) return;
const canvasData = JSON.parse(content) as CanvasData;
if (!canvasData) return;
canvasData.nodes.forEach((node: CanvasNodeData) => {
if (node.type !== 'file') return;
this.renameProperty(node, file.name, newPath);
});
}
const uninstaller = around(this.app.fileManager.constructor.prototype, {
trashFile(old: (file: TFile) => Promise<void>) {
return function(this: FileManager, file: TFile) {
void deleteCanvasFile(file);
void deleteFile(file);
return old.call(this, file);
};
},
renameFile(old: (file: TFile, newPath: string) => Promise<void>) {
return function(this: FileManager, file: TFile, newPath: string) {
void 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) => {
this.optimizeEdgesBetweenSelectedNodes(canvas);
})
});
this.addCommand({
id: 'delete-edges',
name: 'Delete edges between selected nodes',
checkCallback: this.ifActiveViewIsCanvas((canvas) => {
this.deleteEdges(canvas);
})
});
this.addCommand({
id: 'add-link-and-optimize-edge',
name: 'Add edges according the links in notes',
checkCallback: this.ifActiveViewIsCanvas((canvas) => {
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) => {
void this.removeAllProperty(canvas, canvas.getData());
})
});
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() {
let clickedSourceFile: string | null = null;
const handleClick = (evt: MouseEvent) => {
const target = evt.target as HTMLElement;
if (target.closest('.metadata-container') || target.closest('.search-result-container')) {
this.isMetadataClicked = true;
const activeView = this.app.workspace.getActiveViewOfType(FileView);
clickedSourceFile = activeView?.file?.path ?? null;
window.setTimeout(() => {
this.isMetadataClicked = false;
clickedSourceFile = null;
}, 500);
}
};
// DOM events don't cross window boundaries, so attach to every window's
// document — main, any popouts open at load, and any opened later.
const attachedDocs = new WeakSet<Document>();
const attachTo = (doc: Document) => {
if (attachedDocs.has(doc)) return;
attachedDocs.add(doc);
this.registerDomEvent(doc, 'click', handleClick, true);
};
attachTo(document);
this.app.workspace.iterateAllLeaves((leaf) => {
const doc = leaf.view.containerEl.ownerDocument;
if (doc) attachTo(doc);
});
this.registerEvent(
this.app.workspace.on('window-open', (_workspaceWindow, win: Window) => {
attachTo(win.document);
})
);
this.registerEvent(
this.app.workspace.on('active-leaf-change', () => {
Promise.resolve().then(async () => {
if (this.isMetadataClicked == false || !clickedSourceFile) return;
// get current active leaf
const activeLeaf = this.app.workspace.getActiveViewOfType(ItemView);
if (!activeLeaf || activeLeaf.getViewType() !== 'canvas') return;
const prevFile = clickedSourceFile;
if (!prevFile) return;
const canvas = (activeLeaf as CanvasView).canvas;
if (!canvas) return;
for (const value of canvas.nodes.values()) {
if (value?.filePath === prevFile) {
canvas.select(value);
}
}
window.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 processNodeUpdate = async (e: CanvasEdge) => {
if (!this.settings.enableFrontmatter) return;
const fromNode = e?.from?.node;
const toNode = e?.to?.node;
if (!fromNode || !toNode) return;
if (!fromNode?.filePath) return;
const fromFile = this.app.vault.getFileByPath(fromNode.filePath);
if (!fromFile) return;
// The sync is debounced, so the canvas may have unloaded meanwhile.
const canvasFile = e.canvas?.view?.file;
if (!canvasFile) return;
const canvasName = canvasFile.basename;
const resolvedLinks = this.app.metadataCache.resolvedLinks[fromNode.filePath] || {};
const fromNodeLinks = Object.keys(resolvedLinks);
const { edges, nodes } = e.canvas.getData();
const nodeById = new Map<string, CanvasNodeData>();
const sameFileNodeIds = new Set<string>();
for (const node of nodes) {
nodeById.set(node.id, node);
if (node.file === fromNode.filePath) sameFileNodeIds.add(node.id);
}
// Node gone from the canvas (closed or node deleted) — removeNodeUpdate
// owns that cleanup; reconciling against the now-empty data would
// wrongly strip every link.
if (!sameFileNodeIds.has(fromNode.id)) return;
const edgeToNodesFilePathSet = new Set<string>();
for (const edge of edges) {
if (!sameFileNodeIds.has(edge.fromNode)) continue;
const targetNode = nodeById.get(edge.toNode);
if (targetNode?.type === 'file') edgeToNodesFilePathSet.add(targetNode.file);
}
const toLink = (path: string, file: TFile) =>
this.app.fileManager.generateMarkdownLink(file, path).replace(/^!(\[\[.*\]\])$/, '$1');
const linksToRemove: string[] = [];
fromNodeLinks.forEach(filePath => {
if (edgeToNodesFilePathSet.has(filePath)) return;
if (filePath === canvasFile.path) return;
const targetFile = this.app.vault.getFileByPath(filePath);
if (!targetFile) return;
linksToRemove.push(toLink(filePath, targetFile));
});
// Only add the link when the edge is still present in the canvas data —
// the debounced sync may run after the edge was already deleted again.
let linkToAdd: string | null = null;
if (toNode.filePath && fromNode.filePath !== toNode.filePath && edgeToNodesFilePathSet.has(toNode.filePath)) {
const targetFile = this.app.vault.getFileByPath(toNode.filePath);
if (targetFile) linkToAdd = toLink(toNode.filePath, targetFile);
}
// Early skip via the metadata cache so a clean canvas open issues no
// writes. The cache lags right after a write, so it only gates the
// call — the actual add/remove decisions are made against the real
// frontmatter inside processFrontMatter below.
const cachedLinks = frontmatterValueToArray(
this.app.metadataCache.getFileCache(fromFile)?.frontmatter?.[canvasName]
);
const cacheOutOfDate =
linksToRemove.some(link => cachedLinks.includes(link)) ||
(linkToAdd !== null && !cachedLinks.includes(linkToAdd));
if (!cacheOutOfDate) return;
await this.app.fileManager.processFrontMatter(fromFile, (fm) => {
writeLinkSet(fm, canvasName, (links) => {
const removeSet = new Set(linksToRemove);
const next = links.filter(link => !removeSet.has(link));
if (linkToAdd && !next.includes(linkToAdd)) next.push(linkToAdd);
return next;
});
ensureCanvasKeyOrder(fm, [canvasName]);
});
};
// Edges touched within the debounce window all get processed; repeated
// updates of the same edge collapse into one slot. Opening a canvas
// re-adds every edge, so the addEdge patch queues here too: the flush
// then runs after the import finishes, against complete canvas data —
// an immediate sync would reconcile against partially imported edges
// and strip links that are still being loaded.
const pendingEdgeUpdates = new Set<CanvasEdge>();
const flushEdgeUpdates = debounce(() => {
const pending = Array.from(pendingEdgeUpdates);
pendingEdgeUpdates.clear();
for (const edge of pending) {
void processNodeUpdate(edge);
}
}, 500, true);
// edge.update() fires on every geometric change (node drag, pan, layout),
// but the frontmatter sync only depends on what the edge connects. Track
// each edge's endpoints and queue a sync only when they change. The first
// sighting is the baseline: new edges are already synced by the addEdge
// patch below.
const edgeConnectivity = new WeakMap<CanvasEdge, string>();
const updateTargetNode = (e: CanvasEdge) => {
const connectivity =
`${e.from?.node?.id ?? ''}:${e.from?.node?.filePath ?? ''}` +
`->${e.to?.node?.id ?? ''}:${e.to?.node?.filePath ?? ''}`;
const previous = edgeConnectivity.get(e);
if (previous === connectivity) return;
edgeConnectivity.set(e, connectivity);
if (previous === undefined) return;
pendingEdgeUpdates.add(e);
flushEdgeUpdates();
};
/**
* Synchronizes file metadata with the canvas state by removing the frontmatter reference in the source file
* when the corresponding visual connection to the target file is no longer present.
*/
const updateOriginalNode = async (edge: CanvasEdge) => {
const toNode = edge.to.node;
const fromNode = edge.from.node;
const toFilePath = toNode?.filePath;
const fromFilePath = fromNode?.filePath;
if (!toFilePath || !fromFilePath) return;
const canvasName = edge.canvas.view.file.basename;
const file = this.app.vault.getFileByPath(toFilePath);
if (!file) return;
let link = this.app.fileManager.generateMarkdownLink(file, toFilePath);
link = link.replace(/^!(\[\[.*\]\])$/, '$1'); // for image links
const fromFile = this.app.vault.getFileByPath(fromFilePath);
if (!fromFile) return;
const { edges, nodes } = edge.canvas.getData();
const sameFileNodeIds = new Set(
nodes.filter((node: CanvasNodeData) => node.file === fromFilePath).map((node: CanvasNodeData) => node.id)
);
const stillHasConnection = edges.some((e: CanvasEdgeData) =>
sameFileNodeIds.has(e.fromNode) &&
e.toNode === toNode.id &&
e.fromNode !== fromNode.id
);
if (!stillHasConnection) {
void this.updateFrontmatter(fromFile, link, 'remove', canvasName);
}
};
// remove the node frontmatter when the node is removed
const removeNodeUpdate = (node: CanvasNode) => {
if (node?.file?.extension !== 'md') return;
const canvasFile = node?.canvas?.view?.file;
if (!canvasFile || !canvasFile.name) return;
if (node?.filePath) {
// Check if other nodes in the canvas are using the same source file
const canvasData = node.canvas.getData();
const otherNodes = canvasData.nodes.filter(
(n: CanvasNodeData) => n.file === node.filePath
);
// Only remove the property if no other nodes are using the same source file
if (otherNodes.length === 0) {
// use the method for JSON node to remove the property named after the canvas file name.
const tmpNode: { file?: string } = { file: node.filePath };
void this.removeProperty(tmpNode, canvasFile.name, canvasFile.basename);
}
}
};
// aims to add the canvas file link to the property named after the canvas file name.
const addNodeUpdate = (node: CanvasNode) => {
if (node?.file?.extension !== 'md') return;
const canvasFile = node.canvas.view.file;
if (!canvasFile || !canvasFile.name) return;
if (node.filePath) {
// use the method for JSON node to add the property named after the canvas file name.
const tmpNode: { file?: string } = { file: node.filePath };
void this.addProperty(tmpNode, canvasFile.name, canvasFile.basename);
}
};
const patchedEdgeConstructors = new WeakSet<object>();
const selfPatched = (edge: CanvasEdge) => {
if (patchedEdgeConstructors.has(edge.constructor)) return;
patchedEdgeConstructors.add(edge.constructor);
const uninstaller = around(edge.constructor.prototype, {
update: (next: (...args: unknown[]) => unknown) => {
return function (this: CanvasEdge, ...args: unknown[]) {
const result = next.call(this, ...args);
updateTargetNode(this);
return result;
};
}
});
this.register(uninstaller);
};
let canvasPatched = false;
const patchCanvas = () => {
if (canvasPatched) return false;
const canvasView = this.app.workspace.getLeavesOfType('canvas')
.find(l => (l.view as CanvasView)?.canvas != null)?.view as CanvasView | undefined;
if (!canvasView?.canvas) return false;
const uninstaller = around(canvasView.canvas.constructor.prototype, {
removeNode(old: (node: CanvasNode) => void) {
return function(this: Canvas, node: CanvasNode) {
const result = old.call(this, node);
if (this.isClearing !== true) {
removeNodeUpdate(node);
}
return result;
};
},
addNode(old: (node: CanvasNode) => void) {
return function(this: Canvas, node: CanvasNode) {
const result = old.call(this, node);
addNodeUpdate(node);
return result;
};
},
removeEdge(old: (edge: CanvasEdge) => void) {
return function(this: Canvas, edge: CanvasEdge) {
const result = old.call(this, edge);
if (this.isClearing !== true) {
void updateOriginalNode(edge);
}
return result;
};
},
addEdge(old: (edge: CanvasEdge) => void) {
return function(this: Canvas, edge: CanvasEdge) {
const result = old.call(this, edge);
selfPatched(edge);
pendingEdgeUpdates.add(edge);
flushEdgeUpdates();
return result;
};
},
clear(old: () => void) {
return function(this: Canvas) {
this.isClearing = true;
const result = old.call(this);
queueMicrotask(() => {
this.isClearing = false;
});
return result;
};
}
});
this.register(uninstaller);
canvasPatched = true;
return true;
};
this.registerLazyPatcher(patchCanvas);
}
registerCanvasExploder() {
// For File Nodes - use file-menu event
this.registerEvent(
this.app.workspace.on("file-menu", (menu: Menu) => {
this.exploder.checkAndAddMenu(menu, "Split by headings");
})
);
this.registerEvent(
this.app.workspace.on("editor-menu", (menu: Menu) => {
this.exploder.checkAndAddMenu(menu, "Split by headings");
})
);
// For Text Nodes - patch canvas node menu
this.patchCanvasNodeMenu();
}
/**
* Patches Canvas to add context menu for text nodes (which don't trigger file-menu).
*/
patchCanvasNodeMenu() {
// eslint-disable-next-line @typescript-eslint/no-this-alias -- alias needed: `this` inside the around() patch rebinds to the Canvas node, so the plugin reference must be captured in an outer variable
const plugin = this;
this.registerLazyPatcher(() => {
const canvasView = this.app.workspace.getLeavesOfType("canvas")
.find(l => (l.view as CanvasView)?.canvas?.nodes?.size)?.view as CanvasView | undefined;
const anyNode = canvasView?.canvas?.nodes?.values()?.next()?.value;
if (!anyNode) return false;
const basePrototype = Object.getPrototypeOf(Object.getPrototypeOf(anyNode));
if (!basePrototype?.showMenu) return false;
const uninstall = around(basePrototype, {
showMenu: (next: (menu: Menu, ...args: unknown[]) => unknown) => {
return function(this: CanvasNode, menu: Menu, ...args: unknown[]) {
const result = next.call(this, menu, ...args);
// Add menu for text nodes
if (this.text !== undefined && !this.file) {
plugin.exploder.addTextNodeMenu(menu, this);
}
return result;
};
}
});
this.register(uninstall);
return true;
});
}
/**
* 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(): boolean {
if (this.autoHeightUninstaller) return false;
const canvasView = this.app.workspace.getLeavesOfType("canvas")
.find(l => (l.view as CanvasView)?.canvas?.nodes?.size)?.view;
if (!canvasView) return false;
const canvas = (canvasView as 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);
type PatchFn = (originalMethod: (...args: unknown[]) => unknown) => (...args: unknown[]) => unknown;
const methodsToPatch: Record<string, PatchFn> = {};
if (baseNodePrototype.onResizeDblclick) {
methodsToPatch.onResizeDblclick = (originalMethod) => {
return function(this: CanvasNodeWithFlag, ...args: unknown[]) {
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(this: CanvasNodeWithFlag, ...args: unknown[]) {
const [event, direction] = args;
const result = originalMethod.apply(this, args);
if (direction === "bottom") {
if (this._autoHeightTimer) {
window.clearTimeout(this._autoHeightTimer);
}
this._autoHeightTimer = window.setTimeout(() => {
this.autoHeightEnabled = false;
this._autoHeightTimer = null;
}, 300);
}
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(this: CanvasNodeWithFlag, ...args: unknown[]) {
const result = originalMethod.apply(this, args);
if (this.autoHeightEnabled) {
window.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.autoHeightUninstaller = around(baseNodePrototype, methodsToPatch);
this.register(this.autoHeightUninstaller);
return true;
}
registerCanvasNodeAutoHeightPatcher() {
this.registerLazyPatcher(() => this.patchCanvasNodeAutoHeight());
}
/**
* Writes the user's configured default sizes into a Canvas instance's config so that
* every native creation path (double-click, paste, programmatic createTextNode/createFileNode)
* picks them up. Touches in-memory state only.
*/
applyDefaultNodeSizeToCanvas(canvas: Canvas) {
if (!canvas?.config) return;
canvas.config.defaultTextNodeDimensions = {
width: this.settings.defaultTextNodeWidth,
height: this.settings.defaultTextNodeHeight,
};
canvas.config.defaultFileNodeDimensions = {
width: this.settings.defaultFileNodeWidth,
height: this.settings.defaultFileNodeHeight,
};
}
applyDefaultNodeSizeToAllOpenCanvases() {
for (const leaf of this.app.workspace.getLeavesOfType('canvas')) {
const canvas = (leaf.view as CanvasView)?.canvas;
if (canvas) this.applyDefaultNodeSizeToCanvas(canvas);
}
}
registerCanvasDefaultNodeSize() {
const apply = () => this.applyDefaultNodeSizeToAllOpenCanvases();
this.registerEvent(this.app.workspace.on('active-leaf-change', apply));
this.registerEvent(this.app.workspace.on('layout-change', apply));
this.app.workspace.onLayoutReady(apply);
apply();
}
/**
* Patches Canvas.prototype.dragTempNode so dragging a note in from the file explorer
* uses the user's configured file-node size instead of whatever Obsidian's drag handler
* computed upstream of canvas.config.
*/
patchCanvasDragTempNode(): boolean {
if (this.dragTempNodeUninstaller) return false;
const canvasView = this.app.workspace.getLeavesOfType('canvas')
.find(l => (l.view as CanvasView)?.canvas != null)?.view as CanvasView | undefined;
const canvas = canvasView?.canvas;
if (!canvas?.constructor?.prototype?.dragTempNode) return false;
// eslint-disable-next-line @typescript-eslint/no-this-alias -- alias needed: `this` inside the around() patch rebinds to the Canvas instance, so the plugin reference must be captured in an outer variable
const plugin = this;
const uninstall = around(canvas.constructor.prototype, {
dragTempNode(orig: (dragEvent: unknown, nodeSize: Size, onDropped: (position: Position) => void) => void) {
return function (this: Canvas, dragEvent: unknown, _nodeSize: Size, onDropped: (position: Position) => void) {
const overridden: Size = {
width: plugin.settings.defaultFileNodeWidth,
height: plugin.settings.defaultFileNodeHeight,
};
return orig.call(this, dragEvent, overridden, onDropped);
};
},
});
this.dragTempNodeUninstaller = uninstall;
this.register(uninstall);
return true;
}
registerCanvasDragTempNodePatcher() {
this.registerLazyPatcher(() => this.patchCanvasDragTempNode());
}
createEdge(
node1: Pick<CanvasNodeData, 'id' | 'x' | 'y' | 'width' | 'height'>,
node2: Pick<CanvasNodeData, 'id' | 'x' | 'y' | 'width' | 'height'>,
) {
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: NodeSide;
let toSide: NodeSide;
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: CanvasEdgeData = {
id: randomId(),
fromSide: fromSide,
fromNode: node1.id,
toSide: toSide,
toNode: node2.id
};
return edgeData;
}
/**
* Performs a comprehensive cleanup on all markdown notes when the plugin
* is unloaded, ensuring any custom properties or data managed by this
* plugin are removed from the vault.
*/
onunload() {
// canvasStackInterval is registered via registerInterval, so Obsidian
// clears it on unload.
activeDocument.body.classList.remove('enhanced-canvas-enabled');
this.sendToCanvas.clearSelectedCanvas(false);
void this.cleanupCanvasProperties();
}
/**
* Strips every plugin-managed property from every markdown note: the
* `canvas` property plus each per-canvas property (named after a canvas
* basename). Note-driven rather than canvas-driven so it also removes
* orphans left behind by deleted/renamed canvases or removed nodes, and
* it works regardless of `enableFrontmatter`. Returns the paths of notes
* whose frontmatter could not be updated.
*/
async cleanupCanvasProperties(): Promise<string[]> {
const canvasBasenames = new Set(
this.app.vault.getFiles()
.filter(file => file.extension === 'canvas')
.map(file => file.basename)
);
// '[[folder/Name.canvas|Alias]]' -> 'Name'
const linkToBasename = (link: string) => {
const target = link.replace(/^\[\[(.*)\]\]$/, '$1').split('|')[0];
const base = target.substring(target.lastIndexOf('/') + 1);
return base.endsWith('.canvas') ? base.slice(0, -'.canvas'.length) : base;
};
const failedFiles: string[] = [];
await Promise.all(this.app.vault.getMarkdownFiles().map(async (file) => {
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (!cached) return;
if (!('canvas' in cached) && !Object.keys(cached).some(key => canvasBasenames.has(key))) return;
try {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (!frontmatter) return;
for (const link of frontmatterValueToArray(frontmatter.canvas)) {
delete frontmatter[linkToBasename(link)];
}
delete frontmatter.canvas;
for (const key of Object.keys(frontmatter)) {
if (canvasBasenames.has(key)) delete frontmatter[key];
}
});
} catch (error) {
console.error("Enhanced Canvas: Failed to clean properties from note", file.path, error);
failedFiles.push(file.path);
}
}));
return failedFiles;
}
}
/** Settings tab UI for Enhanced Canvas. */
class EnhancedCanvasSettingTab extends PluginSettingTab {
plugin: EnhancedCanvas;
constructor(app: App, plugin: EnhancedCanvas) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Enable Frontmatter Synchronization')
.setDesc(
'When enabled, canvas nodes and edges are mapped to file metadata (properties). '
)
.addToggle(toggle =>
toggle
.setValue(this.plugin.settings.enableFrontmatter)
.onChange(async (value) => {
if (!value) {
// Disable first so live canvas handlers can't re-add
// properties mid-sweep; the sweep ignores the setting.
this.plugin.settings.enableFrontmatter = false;
await this.plugin.saveSettings();
const failedFiles = await this.plugin.cleanupCanvasProperties();
if (failedFiles.length > 0) {
new Notice(`Failed to clean up properties for ${failedFiles.length} notes. Check console.`);
this.plugin.settings.enableFrontmatter = true;
await this.plugin.saveSettings();
toggle.setValue(true);
}
return;
}
this.plugin.settings.enableFrontmatter = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Enable Custom Visuals & CSS')
.setDesc(
'When enabled, hide the metadata panel (properties) inside Canvas Node (and embedded notes and iframe previews).'
)
.addToggle(toggle =>
toggle
.setValue(this.plugin.settings.enableCustomCSS)
.onChange(async (value) => {
this.plugin.settings.enableCustomCSS = value;
await this.plugin.saveSettings();
this.plugin.toggleCSSClass(value);
})
);
const MIN_NODE_DIMENSION = 50;
const MAX_NODE_DIMENSION = 5000;
new Setting(containerEl).setName('Default node size').setHeading();
const sizeRow = (
name: string,
desc: string,
key: 'defaultTextNodeWidth' | 'defaultTextNodeHeight' | 'defaultFileNodeWidth' | 'defaultFileNodeHeight',
) => {
new Setting(containerEl)
.setName(name)
.setDesc(desc)
.addText((text) =>
text
.setValue(String(this.plugin.settings[key]))
.onChange(async (raw) => {
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n)) return;
if (n < MIN_NODE_DIMENSION || n > MAX_NODE_DIMENSION) return;
this.plugin.settings[key] = n;
await this.plugin.saveSettings();
this.plugin.applyDefaultNodeSizeToAllOpenCanvases();
})
);
};
sizeRow('Text node width', 'Initial width (px) for new text cards. 505000.', 'defaultTextNodeWidth');
sizeRow('Text node height', 'Initial height (px) for new text cards. 505000.', 'defaultTextNodeHeight');
sizeRow('File node width', 'Initial width (px) for new file nodes. 505000.', 'defaultFileNodeWidth');
sizeRow('File node height', 'Initial height (px) for new file nodes. 505000.', 'defaultFileNodeHeight');
}
}