diff --git a/README.md b/README.md
index 93175f2..d09ab6a 100644
--- a/README.md
+++ b/README.md
@@ -6,22 +6,29 @@ A plugin for [Obsidian](https://obsidian.md) that helps you collapse node in can
## Usage
-- Commands
- - `Fold All Nodes` - Collapses all nodes in the workspace
- - `Expand All Nodes` - Expands all nodes in the workspace
- - `Fold Selected Nodes` - Collapses only the selected nodes
- - `Expand Selected Nodes` - Expands only the selected nodes
-- Context menu in canvas
- - `Selection Menu` - Shows the context menu for selected nodes
- - `Node Menu` - Shows the context menu for a specific node or nodes
-- Direct Click on the Header
- - `Click` - Collapse or expand the node
+- Commands
+ - `Fold All Nodes` - Collapses all nodes in the workspace
+ - `Expand All Nodes` - Expands all nodes in the workspace
+ - `Fold Selected Nodes` - Collapses only the selected nodes
+ - `Expand Selected Nodes` - Expands only the selected nodes
+- Context menu in canvas
+ - `Selection Menu` - Shows the context menu for selected nodes
+ - `Node Menu` - Shows the context menu for a specific node or nodes
+ - `Set Node Alias` - Set a custom alias for the node (shown in collapsed state)
+ - `Set Node Thumbnail` - Set a custom thumbnail image for the node (shown in collapsed state)
+ - `Remove Node Customizations` - Remove alias and thumbnail from the node
+- Direct Click on the Header
+ - `Click` - Collapse or expand the node
+- Thumbnails and Aliases in Collapsed State
+ - Enable in settings to show thumbnails and/or aliases when nodes are collapsed
+ - For thumbnails: Add `thumbnail: path/to/image.jpg` or `thumbnail: https://example.com/image.jpg` to node metadata, or use the context menu
+ - For aliases: Add `alias: Your Alias Text` to node metadata, use frontmatter aliases for file nodes, or use the context menu
## Installation
-- Not ready for market yet
-- Can be installed via the [Brat](https://github.com/TfTHacker/obsidian42-brat) plugin
-- Manual installation
+- Not ready for market yet
+- Can be installed via the [Brat](https://github.com/TfTHacker/obsidian42-brat) plugin
+- Manual installation
1. Find the release page on this github page and click
2. Download the latest release zip file
@@ -38,4 +45,3 @@ on [https://www.buymeacoffee.com/boninall](https://www.buymeacoffee.com/boninall
.
-
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index 3f676e2..ad4da5d 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -1,42 +1,44 @@
import esbuild from "esbuild";
import process from "process";
-import builtins from 'builtin-modules'
+import builtins from "builtin-modules";
-const banner =
- `/*
+const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
-const prod = (process.argv[2] === 'production');
+const prod = process.argv[2] === "production";
-esbuild.build({
- banner: {
- js: banner,
- },
- entryPoints: ['src/canvasCollapseIndex.ts'],
- bundle: true,
- external: [
- 'obsidian',
- 'electron',
- '@codemirror/autocomplete',
- '@codemirror/collab',
- '@codemirror/commands',
- '@codemirror/language',
- '@codemirror/lint',
- '@codemirror/search',
- '@codemirror/state',
- '@codemirror/view',
- '@lezer/common',
- '@lezer/highlight',
- '@lezer/lr',
- ...builtins],
- format: 'cjs',
- watch: !prod,
- target: 'es2018',
- logLevel: "info",
- sourcemap: prod ? false : 'inline',
- treeShaking: true,
- outfile: 'main.js',
-}).catch(() => process.exit(1));
+esbuild
+ .build({
+ banner: {
+ js: banner,
+ },
+ entryPoints: ["src/index.ts"],
+ bundle: true,
+ external: [
+ "obsidian",
+ "electron",
+ "@codemirror/autocomplete",
+ "@codemirror/collab",
+ "@codemirror/commands",
+ "@codemirror/language",
+ "@codemirror/lint",
+ "@codemirror/search",
+ "@codemirror/state",
+ "@codemirror/view",
+ "@lezer/common",
+ "@lezer/highlight",
+ "@lezer/lr",
+ ...builtins,
+ ],
+ format: "cjs",
+ watch: !prod,
+ target: "es2018",
+ logLevel: "info",
+ sourcemap: prod ? false : "inline",
+ treeShaking: true,
+ outfile: "main.js",
+ })
+ .catch(() => process.exit(1));
diff --git a/manifest.json b/manifest.json
index d065c0b..8ca52d8 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"id": "collapse-node",
"name": "Collapse Node",
- "version": "1.1.1",
+ "version": "2.0.0",
"minAppVersion": "1.1.0",
"description": "Collapse node in canvas.",
"author": "Boninall",
diff --git a/package.json b/package.json
index 32cf0ae..13deec9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "collapse-node",
- "version": "1.1.1",
+ "version": "2.0.0",
"description": "A plugin for collapse node in canvas",
"main": "main.js",
"scripts": {
@@ -17,7 +17,7 @@
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.14.47",
- "obsidian": "latest",
+ "obsidian": "^1.8.7",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 84d6b64..3b935b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -34,8 +34,8 @@ importers:
specifier: 0.14.47
version: 0.14.47
obsidian:
- specifier: latest
- version: 1.4.4(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)
+ specifier: ^1.8.7
+ version: 1.8.7(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)
tslib:
specifier: 2.4.0
version: 2.4.0
@@ -409,6 +409,7 @@ packages:
eslint@8.48.0:
resolution: {integrity: sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
espree@9.6.1:
@@ -595,8 +596,8 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
- obsidian@1.4.4:
- resolution: {integrity: sha512-q2V5GNT/M40uYOENdVw5kovPSoaO6vppiiyBCkIqWgKp4oN654jA/GQ0OaNBA7p5NdfS245QCeRgCFQ42wOZiw==}
+ obsidian@1.8.7:
+ resolution: {integrity: sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==}
peerDependencies:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
@@ -1299,7 +1300,7 @@ snapshots:
natural-compare@1.4.0: {}
- obsidian@1.4.4(@codemirror/state@6.4.1)(@codemirror/view@6.28.1):
+ obsidian@1.8.7(@codemirror/state@6.4.1)(@codemirror/view@6.28.1):
dependencies:
'@codemirror/state': 6.4.1
'@codemirror/view': 6.28.1
diff --git a/src/ControlHeader.ts b/src/ControlHeader.ts
index fe55165..4895df3 100644
--- a/src/ControlHeader.ts
+++ b/src/ControlHeader.ts
@@ -1,38 +1,49 @@
import {
- CanvasFileNode,
- CanvasGroupNode,
- CanvasLinkNode,
- CanvasNode,
- CanvasTextNode,
+ type CanvasFileNode,
+ type CanvasGroupNode,
+ type CanvasLinkNode,
+ type CanvasNode,
+ type CanvasTextNode,
Component,
- setIcon
+ parseFrontMatterAliases,
+ setIcon,
} from "obsidian";
-import { HeaderComponent } from "./types/custom";
-import { CanvasData, CanvasNodeData } from "obsidian/canvas";
-import CanvasCollapsePlugin from "./canvasCollapseIndex";
+import type { HeaderComponent } from "./types/custom";
+import type CanvasCollapsePlugin from ".";
+import type { AllCanvasNodeData } from "obsidian/canvas";
-export default class CollapseControlHeader extends Component implements HeaderComponent {
- private collapsed: boolean = false;
+export default class CollapseControlHeader
+ extends Component
+ implements HeaderComponent
+{
+ private collapsed = false;
private collapsedIconEl: HTMLDivElement;
private typeIconEl: HTMLDivElement;
private titleEl: HTMLSpanElement;
private headerEl: HTMLElement;
+ private thumbnailEl: HTMLImageElement;
+ private aliasEl: HTMLSpanElement;
- private content: string = "";
+ private content = "";
private node: CanvasNode;
+ private alias = "";
+ private thumbnailUrl = "";
- private refreshed: boolean = false;
- private containingNodes: any[] = [];
+ private refreshed = false;
+ private containingNodes: CanvasNode[] = [];
plugin: CanvasCollapsePlugin;
- oldFilePath: string = "";
+ oldFilePath = "";
constructor(plugin: CanvasCollapsePlugin, node: CanvasNode) {
super();
this.plugin = plugin;
this.node = node;
- this.collapsed = node.unknownData.collapsed === undefined ? false : node.unknownData.collapsed;
+ this.collapsed =
+ node.unknownData.collapsed === undefined
+ ? false
+ : node.unknownData.collapsed;
}
onload() {
@@ -42,20 +53,20 @@ export default class CollapseControlHeader extends Component implements HeaderCo
this.updateNodesInGroup();
this.updateNode();
- this.registerEvent(this.plugin.app.vault.on('rename', (file, oldPath) => {
- if (oldPath === this.oldFilePath) {
- this.titleEl.setText(file.name.split(".")[0]);
- this.oldFilePath = file.path;
- }
- }));
+ this.registerEvent(
+ this.plugin.app.vault.on("rename", (file, oldPath) => {
+ if (oldPath === this.oldFilePath) {
+ this.titleEl.setText(file.name.split(".")[0]);
+ this.oldFilePath = file.path;
+ }
+ })
+ );
return this.headerEl;
}
onunload() {
super.onunload();
-
-
}
unload() {
@@ -67,7 +78,7 @@ export default class CollapseControlHeader extends Component implements HeaderCo
initHeader() {
this.headerEl = createEl("div", {
- cls: "canvas-node-collapse-control"
+ cls: "canvas-node-collapse-control",
});
this.registerDomEvent(this.headerEl, "click", async (evt) => {
evt.preventDefault();
@@ -76,15 +87,23 @@ export default class CollapseControlHeader extends Component implements HeaderCo
});
this.collapsedIconEl = this.headerEl.createEl("div", {
- cls: "canvas-node-collapse-control-icon"
+ cls: "canvas-node-collapse-control-icon",
});
this.typeIconEl = this.headerEl.createEl("div", {
- cls: "canvas-node-type-icon"
+ cls: "canvas-node-type-icon",
});
this.titleEl = this.headerEl.createEl("span", {
- cls: "canvas-node-collapse-control-title"
+ cls: "canvas-node-collapse-control-title",
+ });
+
+ this.thumbnailEl = this.node.nodeEl.createEl("img", {
+ cls: "canvas-node-collapse-control-thumbnail",
+ });
+
+ this.aliasEl = this.headerEl.createEl("span", {
+ cls: "canvas-node-collapse-control-alias",
});
}
@@ -98,31 +117,163 @@ export default class CollapseControlHeader extends Component implements HeaderCo
initContent() {
this.setIconOrContent("setContent");
- this.titleEl.setText(this.content?.replace(/^\#{1,} /g, ''));
+ this.titleEl.setText(this.content?.replace(/^\#{1,} /g, ""));
+ this.initAlias();
+ this.initThumbnail();
+ }
+
+ initAlias() {
+ // Try to get alias from node metadata
+ if (this.node.unknownData?.alias) {
+ this.alias = this.node.unknownData.alias;
+ } else {
+ // For file nodes, try to get alias from frontmatter
+ const fileNode = this.node as CanvasFileNode;
+ if (fileNode.file && this.plugin.app.metadataCache) {
+ try {
+ const meta = this.plugin.app.metadataCache.getFileCache(
+ fileNode.file
+ );
+ if (meta?.frontmatter) {
+ const aliases = parseFrontMatterAliases(
+ meta.frontmatter
+ );
+ if (aliases && aliases.length > 0) {
+ this.alias = aliases[0];
+ }
+ }
+ } catch (e) {
+ console.debug("Error getting alias:", e);
+ }
+ }
+ }
+
+ if (this.alias) {
+ this.aliasEl.setText(this.alias);
+ } else {
+ this.aliasEl.detach();
+ }
+ }
+
+ updateNodeAlias(newAlias: string) {
+ this.alias = newAlias;
+
+ if (this.alias) {
+ // Re-attach if it was detached
+ if (!this.aliasEl.parentElement) {
+ this.headerEl.appendChild(this.aliasEl);
+ }
+ this.aliasEl.setText(this.alias);
+ } else {
+ this.aliasEl.detach();
+ }
+ }
+
+ initThumbnail() {
+ // Try to get thumbnail from node metadata
+ if (this.node.unknownData?.thumbnail) {
+ this.thumbnailUrl = this.node.unknownData.thumbnail;
+ } else {
+ // For file nodes, try to get thumbnail from frontmatter
+ const fileNode = this.node as CanvasFileNode;
+ if (fileNode.file && this.plugin.app.metadataCache) {
+ try {
+ const meta = this.plugin.app.metadataCache.getFileCache(
+ fileNode.file
+ );
+ if (meta?.frontmatter?.thumbnail) {
+ this.thumbnailUrl = meta.frontmatter.thumbnail;
+ }
+ } catch (e) {
+ console.debug("Error getting thumbnail:", e);
+ }
+ }
+ }
+
+ if (this.thumbnailUrl) {
+ try {
+ // Handle both absolute and relative paths
+ const url = this.thumbnailUrl.startsWith("http")
+ ? this.thumbnailUrl
+ : this.plugin.app.vault.adapter.getResourcePath(
+ this.thumbnailUrl
+ );
+
+ this.thumbnailEl.src = url;
+ } catch (e) {
+ console.debug("Error setting thumbnail src:", e);
+ this.thumbnailEl.detach();
+ }
+ } else {
+ this.thumbnailEl.detach();
+ }
+ }
+
+ updateNodeThumbnail(newThumbnailUrl: string) {
+ this.thumbnailUrl = newThumbnailUrl;
+
+ if (this.thumbnailUrl) {
+ try {
+ // Re-attach if it was detached
+ if (!this.thumbnailEl.parentElement) {
+ this.headerEl.appendChild(this.thumbnailEl);
+ }
+
+ // Handle both absolute and relative paths
+ const url = this.thumbnailUrl.startsWith("http")
+ ? this.thumbnailUrl
+ : this.plugin.app.vault.adapter.getResourcePath(
+ this.thumbnailUrl
+ );
+
+ this.thumbnailEl.src = url;
+ } catch (e) {
+ console.debug("Error setting thumbnail src:", e);
+ this.thumbnailEl.detach();
+ }
+ } else {
+ this.thumbnailEl.detach();
+ }
}
setIconOrContent(action: "setIcon" | "setContent") {
const currentType = this.checkNodeType();
switch (currentType) {
case "text":
- if (action === "setIcon") setIcon(this.typeIconEl, "sticky-note");
- if (action === "setContent") this.content = (this.node as CanvasTextNode).text.slice(0, 10) + ((this.node as CanvasTextNode).text.length > 10 ? "..." : "");
+ if (action === "setIcon")
+ setIcon(this.typeIconEl, "sticky-note");
+ if (action === "setContent")
+ this.content =
+ (this.node as CanvasTextNode).text.slice(0, 10) +
+ ((this.node as CanvasTextNode).text.length > 10
+ ? "..."
+ : "");
break;
case "file":
if (action === "setIcon") {
- if ((this.node as CanvasFileNode).file.name.split(".")[1].trim() === "md") setIcon(this.typeIconEl, "file-text");
+ if (
+ (this.node as CanvasFileNode).file.name
+ .split(".")[1]
+ .trim() === "md"
+ )
+ setIcon(this.typeIconEl, "file-text");
else setIcon(this.typeIconEl, "file-image");
}
- if (action === "setContent") this.content = (this.node as CanvasFileNode).file?.name.split(".")[0];
+ if (action === "setContent")
+ this.content = (
+ this.node as CanvasFileNode
+ ).file?.name.split(".")[0];
this.oldFilePath = (this.node as CanvasFileNode).file?.path;
break;
case "group":
- if (action === "setIcon") setIcon(this.typeIconEl, "create-group");
+ if (action === "setIcon")
+ setIcon(this.typeIconEl, "create-group");
if (action === "setContent") this.content = "";
break;
case "link":
if (action === "setIcon") setIcon(this.typeIconEl, "link");
- if (action === "setContent") this.content = (this.node as CanvasLinkNode).url;
+ if (action === "setContent")
+ this.content = (this.node as CanvasLinkNode).url;
break;
}
@@ -131,7 +282,6 @@ export default class CollapseControlHeader extends Component implements HeaderCo
}
}
-
setCollapsed(collapsed: boolean) {
if (this.node.canvas.readonly) return;
if (this.collapsed === collapsed) return;
@@ -150,89 +300,220 @@ export default class CollapseControlHeader extends Component implements HeaderCo
const history = this.node.canvas.history;
if (!history || history.data.length === 0) return;
- history.data.forEach((data: CanvasData) => {
- data.nodes.forEach((node: CanvasNodeData) => {
+ for (const data of history.data) {
+ for (const node of data.nodes) {
if (node.id === this.node.id && node?.collapsed === undefined) {
node.collapsed = false;
}
- });
- });
+ }
+ }
this.refreshed = true;
}
async toggleCollapsed() {
if (this.node.canvas.readonly) return;
+ const wasCollapsed = this.collapsed;
this.collapsed = !this.collapsed;
this.node.unknownData.collapsed = !this.collapsed;
+ // Update visual state first for animation
+ this.updateNode();
+
+ // Add delay to allow for animation to complete before updating other elements
+ setTimeout(() => {
+ // Update nodes in group after a short delay to allow animation to start
+ this.updateNodesInGroup();
+ this.updateEdges();
+ }, 50);
+
this.node.canvas.requestSave(false, true);
const canvasCurrentData = this.node.canvas.getData();
- const nodeData = canvasCurrentData.nodes.find((node: any) => node.id === this.node.id);
+ const nodeData = canvasCurrentData.nodes.find(
+ (node: AllCanvasNodeData) => node.id === this.node.id
+ );
+
if (nodeData) {
nodeData.collapsed = this.collapsed;
+
+ // If this node was collapsed and is now being expanded, move it to the highest z-index
+ if (wasCollapsed && !this.collapsed) {
+ // Remove the node from its current position
+ const nodeIndex = canvasCurrentData.nodes.findIndex(
+ (node: AllCanvasNodeData) => node.id === this.node.id
+ );
+ if (nodeIndex !== -1) {
+ const removedNode = canvasCurrentData.nodes.splice(
+ nodeIndex,
+ 1
+ )[0];
+ // Add it back at the end of the array (highest z-index)
+ canvasCurrentData.nodes.push(removedNode);
+ }
+ }
+
this.refreshHistory();
}
setTimeout(() => {
this.node.canvas.setData(canvasCurrentData);
this.node.canvas.requestSave(true);
- }, 0);
-
- this.updateNodesInGroup();
- this.updateNode();
- this.updateEdges();
+ }, 300); // Increased timeout to match animation duration
}
updateNode() {
this.node.nodeEl.toggleClass("collapsed", this.collapsed);
- setIcon(this.collapsedIconEl, this.collapsed ? "chevron-right" : "chevron-down");
+
+ setIcon(this.collapsedIconEl, "chevron-down");
+ this.collapsedIconEl.toggleClass(
+ ["collapsed", "collapse-handler"],
+ this.collapsed
+ );
+ // Handle thumbnails visibility
+ this.updateThumbnailVisibility();
+
+ // Handle aliases visibility
+ this.updateAliasVisibility();
+ }
+
+ updateThumbnailVisibility() {
+ if (this.collapsed || this.plugin.settings.showThumbnailsAlways) {
+ if (
+ this.plugin.settings.showThumbnailsInCollapsedState &&
+ this.thumbnailUrl &&
+ this.thumbnailUrl !== this.node.unknownData.title
+ ) {
+ this.thumbnailEl.toggleClass("collapsed-node-hidden", false);
+ // Hide the title if we're showing an alias instead
+ this.titleEl.toggleClass("collapsed-node-hidden", true);
+ } else {
+ this.thumbnailEl.toggleClass("collapsed-node-hidden", true);
+ // Make sure title is visible if alias isn't shown
+ this.titleEl.toggleClass("collapsed-node-hidden", false);
+ }
+ } else {
+ this.thumbnailEl.toggleClass("collapsed-node-hidden", true);
+ }
+ }
+
+ updateAliasVisibility() {
+ if (this.collapsed || this.plugin.settings.showAliasesAlways) {
+ if (
+ this.plugin.settings.showAliasesInCollapsedState &&
+ this.alias &&
+ this.alias !== this.node.unknownData.title
+ ) {
+ this.aliasEl.toggleClass("collapsed-node-hidden", false);
+ // Hide the title if we're showing an alias instead
+ this.titleEl.toggleClass("collapsed-node-hidden", true);
+ } else {
+ this.aliasEl.toggleClass("collapsed-node-hidden", true);
+ // Make sure title is visible if alias isn't shown
+ this.titleEl.toggleClass("collapsed-node-hidden", false);
+ }
+ } else {
+ this.aliasEl.toggleClass("collapsed-node-hidden", true);
+ }
}
updateEdges() {
this.node.canvas.nodeInteractionLayer.interactionEl.detach();
this.node.canvas.nodeInteractionLayer.render();
const edges = this.node.canvas.getEdgesForNode(this.node);
- edges.forEach((edge: any) => {
+ for (const edge of edges) {
edge.render();
- });
+ }
}
updateNodesInGroup(expandAll?: boolean) {
- if (this.node.unknownData.type === "group" || (this.node as CanvasGroupNode).label) {
- const nodes = this.node.canvas.getContainingNodes(this.node.getBBox(true));
+ if (
+ this.node.unknownData.type === "group" ||
+ (this.node as CanvasGroupNode).label
+ ) {
+ const nodes = this.node.canvas.getContainingNodes(
+ this.node.getBBox(true)
+ );
if (expandAll) {
this.collapsed = false;
}
+ // Add animation class to the group node
+ this.node.nodeEl.toggleClass("animating", true);
+
+ // Remove animation class after animation completes
+ setTimeout(() => {
+ this.node.nodeEl.toggleClass("animating", false);
+ }, 300);
+
if (this.collapsed) {
- nodes.filter((node: any) => node.id !== this.node.id).forEach((node: any) => {
+ const filteredNodes = nodes.filter(
+ (node: CanvasNode) => node.id !== this.node.id
+ );
+ for (const node of filteredNodes) {
this.containingNodes.push(node);
- node.nodeEl.toggleClass("group-nodes-collapsed", this.collapsed);
+
+ // Add transition class before changing state
+ node.nodeEl.toggleClass("node-transitioning", true);
+
+ // Apply the collapsed state
+ node.nodeEl.toggleClass(
+ "group-nodes-collapsed",
+ this.collapsed
+ );
+
+ // Remove transition class after animation completes
+ setTimeout(() => {
+ node.nodeEl.toggleClass("node-transitioning", false);
+ }, 300);
+
this.updateEdgesInGroup(node);
- });
+ }
} else {
- const otherGroupNodes = nodes.filter((node: any) => node.id !== this.node.id && node.unknownData.type === "group" && node.unknownData.collapsed);
+ const otherGroupNodes = nodes.filter(
+ (node: CanvasNode) =>
+ node.id !== this.node.id &&
+ node.unknownData.type === "group" &&
+ node.unknownData.collapsed
+ );
// Ignore those nodes in collapsed child group
- const ignoreNodes: any[] = [];
+ const ignoreNodes: CanvasNode[] = [];
for (const groupNode of otherGroupNodes) {
const bbox = groupNode.getBBox(true);
- const nodesInGroup = this.node.canvas.getContainingNodes(bbox);
- nodesInGroup.forEach((childNode: any) => {
+ const nodesInGroup =
+ this.node.canvas.getContainingNodes(bbox);
+ for (const childNode of nodesInGroup) {
if (childNode.id !== groupNode.id) {
ignoreNodes.push(childNode);
}
- });
+ }
}
- this.containingNodes.filter((t) => !ignoreNodes.find((k) => k.id === t.id)).forEach((node: any) => {
- node.nodeEl.toggleClass("group-nodes-collapsed", this.collapsed);
- this.updateEdgesInGroup(node);
- });
- ignoreNodes.forEach((node: any) => {
+ const filteredContainingNodes = this.containingNodes.filter(
+ (t) => !ignoreNodes.find((k) => k.id === t.id)
+ );
+
+ for (const node of filteredContainingNodes) {
+ // Add transition class before changing state
+ node.nodeEl.toggleClass("node-transitioning", true);
+
+ // Apply the expanded state
+ node.nodeEl.toggleClass(
+ "group-nodes-collapsed",
+ this.collapsed
+ );
+
+ // Remove transition class after animation completes
+ setTimeout(() => {
+ node.nodeEl.toggleClass("node-transitioning", false);
+ }, 300);
+
+ this.updateEdgesInGroup(node, true);
+ }
+
+ for (const node of ignoreNodes) {
this.updateEdgesInGroup(node, node.unknownData.collapsed);
- });
+ }
this.containingNodes = [];
}
@@ -243,11 +524,23 @@ export default class CollapseControlHeader extends Component implements HeaderCo
updateEdgesInGroup(node: CanvasNode, triggerCollapsed?: boolean) {
const edges = this.node.canvas.getEdgesForNode(node);
- edges.forEach((edge: any) => {
- edge.labelElement?.wrapperEl?.classList.toggle("group-edges-collapsed", triggerCollapsed || this.collapsed);
- edge.lineGroupEl.classList.toggle("group-edges-collapsed", triggerCollapsed || this.collapsed);
- edge.lineEndGroupEl?.classList.toggle("group-edges-collapsed", triggerCollapsed || this.collapsed);
- edge.lineStartGroupEl?.classList.toggle("group-edges-collapsed", triggerCollapsed || this.collapsed);
- });
+ for (const edge of edges) {
+ edge.labelElement?.wrapperEl?.classList.toggle(
+ "group-edges-collapsed",
+ triggerCollapsed || this.collapsed
+ );
+ edge.lineGroupEl.classList.toggle(
+ "group-edges-collapsed",
+ triggerCollapsed || this.collapsed
+ );
+ edge.lineEndGroupEl?.classList.toggle(
+ "group-edges-collapsed",
+ triggerCollapsed || this.collapsed
+ );
+ edge.lineStartGroupEl?.classList.toggle(
+ "group-edges-collapsed",
+ triggerCollapsed || this.collapsed
+ );
+ }
}
}
diff --git a/src/canvasCollapseIndex.ts b/src/index.ts
similarity index 77%
rename from src/canvasCollapseIndex.ts
rename to src/index.ts
index c5cbfe7..24eb4b0 100644
--- a/src/canvasCollapseIndex.ts
+++ b/src/index.ts
@@ -1,31 +1,16 @@
import {
addIcon,
- Canvas,
- CanvasCoords,
- CanvasGroupNode,
- CanvasNode,
- CanvasView,
+ debounce,
editorInfoField,
- Menu,
Plugin,
PluginSettingTab,
- setIcon,
Setting,
- setTooltip,
- ViewState,
- WorkspaceLeaf,
} from "obsidian";
-import { around } from "monkey-around";
import CollapseControlHeader from "./ControlHeader";
-import { CanvasData } from "obsidian/canvas";
import {
- getSelectionCoords,
- handleCanvasMenu,
- handleMultiNodesViaNodes,
handleNodeContextMenu,
handleNodesViaCommands,
- handleSelectionContextMenu,
- handleSingleNode,
+ handleSelectionContextMenu,
refreshAllCanvasView,
} from "./utils";
import { EditorView, ViewUpdate } from "@codemirror/view";
@@ -34,7 +19,6 @@ import {
patchCanvasInteraction,
patchCanvasMenu,
patchCanvasNode,
- updateAllNodeWithHeader,
} from "./patchUtils";
interface CollapsableNodeSettings {
@@ -48,6 +32,11 @@ interface CollapsableNodeSettings {
interface OtherSettings {
minLineAmount: number;
minimalControlHeader: boolean;
+ showThumbnailsInCollapsedState: boolean;
+ showAliasesInCollapsedState: boolean;
+ showAliasesAlways: boolean;
+ showThumbnailsAlways: boolean;
+ hideDefaultNodeTitle: boolean;
}
type CanvasCollapseSettings = CollapsableNodeSettings & OtherSettings;
@@ -61,6 +50,11 @@ const DEFAULT_SETTINGS: CanvasCollapseSettings = {
minLineAmount: 0,
minimalControlHeader: false,
+ showThumbnailsInCollapsedState: false,
+ showAliasesInCollapsedState: false,
+ showAliasesAlways: false,
+ showThumbnailsAlways: false,
+ hideDefaultNodeTitle: false,
};
const DynamicUpdateControlHeader = (plugin: CanvasCollapsePlugin) => {
@@ -108,6 +102,8 @@ const DynamicUpdateControlHeader = (plugin: CanvasCollapsePlugin) => {
(node.containerEl as HTMLDivElement).prepend(
node.headerComponent.onload()
);
+
+ node.headerComponent.updateNode();
}
}
}
@@ -121,22 +117,26 @@ export default class CanvasCollapsePlugin extends Plugin {
settings: CanvasCollapseSettings;
+ headerComponents: { [key: string]: CollapseControlHeader[] } = {};
+
async onload() {
- this.loadSettings();
+ await this.loadSettings();
this.addSettingTab(new CollapseSettingTab(this.app, this));
this.registerCommands();
this.registerCanvasEvents();
this.registerCustomIcons();
- aroundCanvasMethods(this);
- patchCanvasMenu(this);
- patchCanvasInteraction(this);
- patchCanvasNode(this);
-
this.registerEditorExtension([DynamicUpdateControlHeader(this)]);
this.initGlobalCss();
+
+ this.app.workspace.onLayoutReady(() => {
+ aroundCanvasMethods(this);
+ patchCanvasMenu(this);
+ patchCanvasInteraction(this);
+ patchCanvasNode(this);
+ });
}
onunload() {
@@ -208,6 +208,10 @@ export default class CanvasCollapsePlugin extends Plugin {
"minimal-control-header",
this.settings?.minimalControlHeader
);
+ document.body.toggleClass(
+ "hide-default-node-title",
+ this.settings?.hideDefaultNodeTitle
+ );
}
async loadSettings() {
@@ -218,8 +222,17 @@ export default class CanvasCollapsePlugin extends Plugin {
);
}
+ debounceReloadLeaves = debounce(() => {
+ const leaves = this.app.workspace.getLeavesOfType("canvas");
+ leaves.forEach((leaf) => {
+ leaf.rebuildView();
+ });
+ }, 1000);
+
async saveSettings() {
await this.saveData(this.settings);
+
+ this.debounceReloadLeaves();
}
}
@@ -311,6 +324,78 @@ export class CollapseSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
});
+
+ new Setting(containerEl)
+ .setName("Show thumbnails in collapsed state")
+ .setDesc("Show thumbnails in the collapsed state of the node")
+ .addToggle((toggle) => {
+ toggle
+ .setValue(
+ this.plugin.settings.showThumbnailsInCollapsedState
+ )
+ .onChange(async (value) => {
+ this.plugin.settings.showThumbnailsInCollapsedState =
+ value;
+ await this.plugin.saveSettings();
+ });
+ });
+
+ new Setting(containerEl)
+ .setName("Show aliases in collapsed state")
+ .setDesc("Show aliases in the collapsed state of the node")
+ .addToggle((toggle) => {
+ toggle
+ .setValue(this.plugin.settings.showAliasesInCollapsedState)
+ .onChange(async (value) => {
+ this.plugin.settings.showAliasesInCollapsedState =
+ value;
+ await this.plugin.saveSettings();
+ });
+ });
+
+ new Setting(containerEl)
+ .setName("Hide default node title")
+ .setDesc("Hide the default title of the node")
+ .addToggle((toggle) => {
+ toggle
+ .setValue(this.plugin.settings.hideDefaultNodeTitle)
+ .onChange(async (value) => {
+ this.plugin.settings.hideDefaultNodeTitle = value;
+ document.body.toggleClass(
+ "hide-default-node-title",
+ value
+ );
+ await this.plugin.saveSettings();
+ });
+ });
+
+ new Setting(containerEl)
+ .setName("Show aliases always")
+ .setDesc(
+ "Show aliases always in the collapsed/expanded state of the node. Replace current title with alias."
+ )
+ .addToggle((toggle) => {
+ toggle
+ .setValue(this.plugin.settings.showAliasesAlways)
+ .onChange(async (value) => {
+ this.plugin.settings.showAliasesAlways = value;
+ await this.plugin.saveSettings();
+ });
+ });
+
+ new Setting(containerEl)
+ .setName("Show thumbnails always")
+ .setDesc(
+ "Show thumbnails always in the collapsed state of the node"
+ )
+ .addToggle((toggle) => {
+ toggle
+ .setValue(this.plugin.settings.showThumbnailsAlways)
+ .onChange(async (value) => {
+ this.plugin.settings.showThumbnailsAlways = value;
+ await this.plugin.saveSettings();
+ });
+ });
}
createCollapsableSetting(
diff --git a/src/patchUtils.ts b/src/patchUtils.ts
index 97d15d7..3c45397 100644
--- a/src/patchUtils.ts
+++ b/src/patchUtils.ts
@@ -13,7 +13,7 @@ import {
import { CanvasCoords } from "obsidian";
import { CanvasView } from "obsidian";
-import CanvasCollapsePlugin from "./canvasCollapseIndex";
+import CanvasCollapsePlugin from ".";
import { CanvasData } from "obsidian/canvas";
import {
@@ -210,12 +210,18 @@ export const patchCanvasMenu = (plugin: CanvasCollapsePlugin) => {
plugin.triggerByPlugin = true;
};
- const patchMenu = () => {
- const canvasView = plugin.app.workspace
+ const patchMenu = async () => {
+ const canvasLeaf = plugin.app.workspace
.getLeavesOfType("canvas")
- .first()?.view;
- if (!canvasView) return false;
+ .first();
+ if (canvasLeaf?.isDeferred) {
+ await canvasLeaf.loadIfDeferred();
+ }
+
+ const canvasView = canvasLeaf?.view;
+
+ if (!canvasView) return false;
const menu = (canvasView as CanvasView)?.canvas.menu;
if (!menu) return false;
@@ -288,10 +294,10 @@ export const patchCanvasMenu = (plugin: CanvasCollapsePlugin) => {
return true;
};
- plugin.app.workspace.onLayoutReady(() => {
- if (!patchMenu()) {
- const evt = plugin.app.workspace.on("layout-change", () => {
- patchMenu() && plugin.app.workspace.offref(evt);
+ plugin.app.workspace.onLayoutReady(async () => {
+ if (!(await patchMenu())) {
+ const evt = plugin.app.workspace.on("layout-change", async () => {
+ (await patchMenu()) && plugin.app.workspace.offref(evt);
});
plugin.registerEvent(evt);
}
@@ -356,59 +362,55 @@ export const renderNodeWithHeader = (
plugin: CanvasCollapsePlugin,
node: any
) => {
+ // If header already exists, don't create another one
if (node.headerComponent) return;
- if (
- !plugin.settings.collapsableFileNode &&
- node.unknownData.type === "file" &&
- node.file.extension === "md"
- )
- return;
- if (
- !plugin.settings.collapsableAttachmentNode &&
- node.unknownData.type === "file" &&
- node.file.extension !== "md"
- )
- return;
- if (
- !plugin.settings.collapsableGroupNode &&
- node.unknownData.type === "group"
- )
- return;
- if (
- !plugin.settings.collapsableLinkNode &&
- node.unknownData.type === "link"
- )
- return;
- if (
- !plugin.settings.collapsableTextNode &&
- node.unknownData.type === "text"
- )
- return;
+ // Check node type against plugin settings
+ const nodeType = node.unknownData.type;
+ // Return early if this node type is disabled in settings
+ if (
+ nodeType === "file" &&
+ node.file?.extension === "md" &&
+ !plugin.settings.collapsableFileNode
+ )
+ return;
+ if (
+ nodeType === "file" &&
+ node.file?.extension !== "md" &&
+ !plugin.settings.collapsableAttachmentNode
+ )
+ return;
+ if (nodeType === "group" && !plugin.settings.collapsableGroupNode) return;
+ if (nodeType === "link" && !plugin.settings.collapsableLinkNode) return;
+ if (nodeType === "text" && !plugin.settings.collapsableTextNode) return;
+
+ // Check minimum line amount for text and file nodes
if (
plugin.settings.minLineAmount > 0 &&
- (node.unknownData.type === "text" || node.unknownData.type === "file")
+ (nodeType === "text" || nodeType === "file")
) {
- if (
- typeof node.text === "string" &&
- node.text.split("\n").length < plugin.settings.minLineAmount
- )
- return;
- if (
- node.file &&
- node.file.extension === "md" &&
- node.child &&
- node.child.data?.split("\n").length < plugin.settings.minLineAmount
- )
- return;
+ let lineCount = 0;
+
+ if (nodeType === "text" && typeof node.text === "string") {
+ lineCount = node.text.split("\n").length;
+ } else if (
+ nodeType === "file" &&
+ node.file?.extension === "md" &&
+ node.child
+ ) {
+ lineCount = node.child.data?.split("\n").length || 0;
+ }
+
+ if (lineCount < plugin.settings.minLineAmount) return;
}
+ // Create header component
node.headerComponent = initControlHeader(
plugin,
node
) as CollapseControlHeader;
- node.nodeEl.setAttribute("data-node-type", node.unknownData.type);
+ node.nodeEl.setAttribute("data-node-type", nodeType);
// Wait for containerEl to be loaded before adding header
const addHeader = () => {
@@ -422,6 +424,7 @@ export const renderNodeWithHeader = (
};
addHeader();
+ // Apply collapsed state if needed
if (node.unknownData.collapsed) {
node.nodeEl.toggleClass("collapsed", true);
node.headerComponent.updateEdges();
@@ -469,6 +472,8 @@ export const patchCanvasNode = (plugin: CanvasCollapsePlugin) => {
}
}
+ console.log(prototype);
+
if (!prototype) return false;
const uninstaller = around(prototype, {
diff --git a/src/types/obsidian.d.ts b/src/types/obsidian.d.ts
index 1b6f00e..c960adf 100644
--- a/src/types/obsidian.d.ts
+++ b/src/types/obsidian.d.ts
@@ -204,6 +204,11 @@ declare module "obsidian" {
lineEndGroupEl: SVGGElement;
lineGroupEl: SVGGElement;
+ labelElement: {
+ wrapperEl: HTMLElement;
+ textEl: HTMLElement;
+ };
+
path: {
display: SVGPathElement;
interaction: SVGPathElement;
@@ -211,6 +216,7 @@ declare module "obsidian" {
canvas: Canvas;
bbox: CanvasCoords;
+ render(): void;
unknownData: CanvasNodeUnknownData;
}
diff --git a/src/utils.ts b/src/utils.ts
index 7040182..2bbb623 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,6 +1,17 @@
-import { App, Canvas, CanvasCoords, CanvasNode, ItemView, Menu, MenuItem } from "obsidian";
+import {
+ App,
+ Canvas,
+ CanvasCoords,
+ CanvasNode,
+ ItemView,
+ Menu,
+ MenuItem,
+ Modal,
+ SuggestModal,
+ TFile,
+} from "obsidian";
import CollapseControlHeader from "./ControlHeader";
-import CanvasCollapsePlugin from "./canvasCollapseIndex";
+import CanvasCollapsePlugin from ".";
const getBoundingRect = (nodes: CanvasNode[]) => {
const bboxArray = nodes.map((t: CanvasNode) => t.getBBox());
@@ -20,17 +31,29 @@ const getBoundingRect = (nodes: CanvasNode[]) => {
const updateSelection = (canvas: Canvas) => {
if (canvas.menu.selection.bbox) {
const selection = Array.from(canvas.selection);
- const currentNodesInSelection = canvas.getContainingNodes(canvas.menu.selection.bbox);
+ const currentNodesInSelection = canvas.getContainingNodes(
+ canvas.menu.selection.bbox
+ );
if (currentNodesInSelection.length > 0) {
- const boundingRect = getBoundingRect(selection.length > currentNodesInSelection.length ? selection : currentNodesInSelection);
+ const boundingRect = getBoundingRect(
+ selection.length > currentNodesInSelection.length
+ ? selection
+ : currentNodesInSelection
+ );
if (boundingRect) {
canvas.menu.selection.update(boundingRect);
}
}
}
};
-const handleMultiNodes = (canvas: Canvas, allNodes: boolean, collapse: boolean) => {
- const nodes = allNodes ? Array.from(canvas.nodes.values()) : Array.from(canvas.selection) as any[];
+const handleMultiNodes = (
+ canvas: Canvas,
+ allNodes: boolean,
+ collapse: boolean
+) => {
+ const nodes = allNodes
+ ? Array.from(canvas.nodes.values())
+ : (Array.from(canvas.selection) as any[]);
const canvasData = canvas.getData();
if (nodes && nodes.length > 0) {
@@ -39,7 +62,9 @@ const handleMultiNodes = (canvas: Canvas, allNodes: boolean, collapse: boolean)
node.headerComponent.updateNodesInGroup(collapse);
}
node.headerComponent?.setCollapsed(collapse);
- const nodeData = canvasData.nodes.find((t: any) => t.id === node.id);
+ const nodeData = canvasData.nodes.find(
+ (t: any) => t.id === node.id
+ );
if (nodeData) nodeData.collapsed = collapse;
}
canvas.setData(canvasData);
@@ -48,16 +73,26 @@ const handleMultiNodes = (canvas: Canvas, allNodes: boolean, collapse: boolean)
canvas.requestFrame();
updateSelection(canvas);
};
-export const handleMultiNodesViaNodes = (canvas: Canvas, nodes: CanvasNode[], collapse: boolean) => {
+export const handleMultiNodesViaNodes = (
+ canvas: Canvas,
+ nodes: CanvasNode[],
+ collapse: boolean
+) => {
const canvasData = canvas.getData();
if (nodes && nodes.length > 0) {
for (const node of nodes) {
if (node.unknownData.type === "group") {
- (node.headerComponent as CollapseControlHeader).updateNodesInGroup(collapse);
+ (
+ node.headerComponent as CollapseControlHeader
+ ).updateNodesInGroup(collapse);
}
- (node.headerComponent as CollapseControlHeader)?.setCollapsed(collapse);
- const nodeData = canvasData.nodes.find((t: any) => t.id === node.id);
+ (node.headerComponent as CollapseControlHeader)?.setCollapsed(
+ collapse
+ );
+ const nodeData = canvasData.nodes.find(
+ (t: any) => t.id === node.id
+ );
if (nodeData) nodeData.collapsed = collapse;
}
canvas.setData(canvasData);
@@ -76,7 +111,13 @@ export const handleSingleNode = (node: CanvasNode, collapse: boolean) => {
node.canvas.requestSave(true, true);
updateSelection(node.canvas);
};
-export const handleNodesViaCommands = (plugin: CanvasCollapsePlugin, checking: boolean, allNodes: boolean, collapse: boolean) => {
+
+export const handleNodesViaCommands = (
+ plugin: CanvasCollapsePlugin,
+ checking: boolean,
+ allNodes: boolean,
+ collapse: boolean
+) => {
plugin.triggerByPlugin = true;
const currentView = plugin.app.workspace.getActiveViewOfType(ItemView);
if (currentView && currentView.getViewType() === "canvas") {
@@ -89,46 +130,435 @@ export const handleNodesViaCommands = (plugin: CanvasCollapsePlugin, checking: b
return true;
}
};
-const createHandleContextMenu = (section: string, callback: (isFold: boolean) => Promise) => {
+
+const createHandleContextMenu = (
+ section: string,
+ callback: (isFold: boolean) => Promise
+) => {
return (menu: Menu) => {
menu.addItem((item: MenuItem) => {
- const subMenu = item.setSection(section).setTitle('Collapse node').setIcon('chevrons-left-right').setSubmenu();
+ const subMenu = item
+ .setSection(section)
+ .setTitle("Collapse node")
+ .setIcon("chevrons-left-right")
+ .setSubmenu();
handleCanvasMenu(subMenu, callback);
});
};
};
-export const handleCanvasMenu = (subMenu: Menu, callback: (isFold: boolean) => Promise) => {
- return subMenu.addItem((item: MenuItem) => {
- item
- .setIcon("fold-vertical")
- .setTitle("Fold selected nodes")
- .onClick(async () => {
- await callback(true);
- });
- }).addItem((item: any) => {
- item
- .setIcon("unfold-vertical")
- .setTitle("Expand selected nodes")
- .onClick(async () => {
- await callback(false);
- });
- });
+
+export const handleCanvasMenu = (
+ subMenu: Menu,
+ callback: (isFold: boolean) => Promise
+) => {
+ return subMenu
+ .addItem((item: MenuItem) => {
+ item.setIcon("fold-vertical")
+ .setTitle("Fold selected nodes")
+ .onClick(async () => {
+ await callback(true);
+ });
+ })
+ .addItem((item: any) => {
+ item.setIcon("unfold-vertical")
+ .setTitle("Expand selected nodes")
+ .onClick(async () => {
+ await callback(false);
+ });
+ });
};
-export const handleSelectionContextMenu = (plugin: CanvasCollapsePlugin, menu: Menu, canvas: Canvas) => {
+
+export const handleSelectionContextMenu = (
+ plugin: CanvasCollapsePlugin,
+ menu: Menu,
+ canvas: Canvas
+) => {
plugin.triggerByPlugin = true;
const callback = async (isFold: boolean) => {
handleMultiNodes(canvas, false, isFold);
};
- createHandleContextMenu('action', callback)(menu);
+ createHandleContextMenu("action", callback)(menu);
};
-export const handleNodeContextMenu = (plugin: CanvasCollapsePlugin, menu: Menu, node: CanvasNode) => {
+
+export const handleNodeContextMenu = (
+ plugin: CanvasCollapsePlugin,
+ menu: Menu,
+ node: CanvasNode
+) => {
plugin.triggerByPlugin = true;
const callback = async (isFold: boolean) => {
handleSingleNode(node, isFold);
};
- createHandleContextMenu('canvas', callback)(menu);
+ createHandleContextMenu("canvas", callback)(menu);
+
+ // Add Alias and Thumbnail menu items
+ menu.addItem((item: MenuItem) => {
+ item.setSection("canvas")
+ .setTitle("Set Node Alias")
+ .setIcon("text-cursor-input")
+ .onClick(async () => {
+ await setNodeAlias(plugin, node);
+ });
+ });
+
+ menu.addItem((item: MenuItem) => {
+ item.setSection("canvas")
+ .setTitle("Set Node Thumbnail")
+ .setIcon("image")
+ .onClick(async () => {
+ await setNodeThumbnail(plugin, node);
+ });
+ });
+
+ // Add option to remove alias/thumbnail
+ if (node.unknownData.alias || node.unknownData.thumbnail) {
+ menu.addItem((item: MenuItem) => {
+ item.setSection("canvas")
+ .setTitle("Remove Node Customizations")
+ .setIcon("trash")
+ .onClick(async () => {
+ await removeNodeCustomizations(plugin, node);
+ });
+ });
+ }
};
+// Function to set alias for a node
+export const setNodeAlias = async (
+ plugin: CanvasCollapsePlugin,
+ node: CanvasNode
+) => {
+ const modal = new TextInputModal(
+ plugin.app,
+ "Enter alias for node",
+ node.unknownData.alias || "",
+ "Set Alias"
+ );
+
+ const alias = await modal.openAndGetValue();
+ if (alias !== null) {
+ // Set the alias in node data
+ node.unknownData.alias = alias;
+
+ // Update the node if it has a header component
+ if (
+ node.headerComponent &&
+ node.headerComponent instanceof CollapseControlHeader
+ ) {
+ const header = node.headerComponent as CollapseControlHeader;
+ header.updateNodeAlias(alias);
+ header.updateNode();
+ }
+
+ // Save the canvas state
+ node.canvas.requestSave(false, true);
+ }
+};
+
+// Function to set thumbnail for a node
+export const setNodeThumbnail = async (
+ plugin: CanvasCollapsePlugin,
+ node: CanvasNode
+) => {
+ const modal = new ThumbnailSelectionModal(plugin.app);
+ const thumbnailPath = await modal.openAndGetValue();
+
+ if (thumbnailPath) {
+ // Set the thumbnail in node data
+ node.unknownData.thumbnail = thumbnailPath;
+
+ // Update the node if it has a header component
+ if (
+ node.headerComponent &&
+ node.headerComponent instanceof CollapseControlHeader
+ ) {
+ const header = node.headerComponent as CollapseControlHeader;
+ header.updateNodeThumbnail(thumbnailPath);
+ header.updateNode();
+ }
+
+ // Save the canvas state
+ node.canvas.requestSave(false, true);
+ }
+};
+
+// Function to remove alias and thumbnail from node
+export const removeNodeCustomizations = async (
+ plugin: CanvasCollapsePlugin,
+ node: CanvasNode
+) => {
+ delete node.unknownData.alias;
+ delete node.unknownData.thumbnail;
+
+ // Update the node if it has a header component
+ if (
+ node.headerComponent &&
+ node.headerComponent instanceof CollapseControlHeader
+ ) {
+ const header = node.headerComponent as CollapseControlHeader;
+ header.updateNodeAlias("");
+ header.updateNodeThumbnail("");
+ header.updateNode();
+ }
+
+ // Save the canvas state
+ node.canvas.requestSave(false, true);
+};
+
+// Class for text input modal
+class TextInputModal extends Modal {
+ private value: string;
+ private inputEl: HTMLInputElement;
+ private resolvePromise: (value: string | null) => void;
+ private buttonText: string;
+
+ constructor(
+ app: App,
+ private title: string,
+ initialValue: string,
+ buttonText: string
+ ) {
+ super(app);
+ this.value = initialValue;
+ this.buttonText = buttonText;
+ }
+
+ onOpen() {
+ const { contentEl } = this;
+
+ contentEl.createEl("h2", { text: this.title });
+
+ this.inputEl = contentEl.createEl("input", {
+ type: "text",
+ value: this.value,
+ });
+ this.inputEl.style.width = "100%";
+ this.inputEl.style.marginBottom = "1em";
+
+ // Focus input
+ setTimeout(() => this.inputEl.focus(), 10);
+
+ // Add buttons
+ const buttonContainer = contentEl.createDiv();
+ buttonContainer.style.display = "flex";
+ buttonContainer.style.justifyContent = "flex-end";
+ buttonContainer.style.gap = "10px";
+
+ const cancelButton = buttonContainer.createEl("button", {
+ text: "Cancel",
+ });
+ const saveButton = buttonContainer.createEl("button", {
+ text: this.buttonText,
+ cls: "mod-cta",
+ });
+
+ cancelButton.addEventListener("click", () => {
+ this.resolvePromise(null);
+ this.close();
+ });
+
+ saveButton.addEventListener("click", () => {
+ this.resolvePromise(this.inputEl.value);
+ this.close();
+ });
+
+ // Handle Enter key
+ this.inputEl.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ this.resolvePromise(this.inputEl.value);
+ this.close();
+ }
+ });
+ }
+
+ onClose() {
+ const { contentEl } = this;
+ contentEl.empty();
+ }
+
+ openAndGetValue(): Promise {
+ return new Promise((resolve) => {
+ this.resolvePromise = resolve;
+ this.open();
+ });
+ }
+}
+
+// Class for thumbnail selection modal
+class ThumbnailSelectionModal extends Modal {
+ private resolvePromise: (value: string | null) => void;
+ private inputEl: HTMLInputElement;
+ private fileInput: HTMLInputElement;
+ private preview: HTMLImageElement;
+
+ constructor(app: App) {
+ super(app);
+ }
+
+ onOpen() {
+ const { contentEl } = this;
+
+ contentEl.createEl("h2", { text: "Set Node Thumbnail" });
+
+ // URL input
+ contentEl.createEl("p", {
+ text: "Enter image URL or path to an attachment:",
+ });
+
+ this.inputEl = contentEl.createEl("input", {
+ type: "text",
+ placeholder: "https://example.com/image.jpg or image.jpg",
+ });
+ this.inputEl.style.width = "100%";
+ this.inputEl.style.marginBottom = "1em";
+
+ // Or use file picker to select from vault
+ contentEl.createEl("p", {
+ text: "Or select an image from your attachments:",
+ });
+
+ const selectFileButton = contentEl.createEl("button", {
+ text: "Browse vault files",
+ });
+ selectFileButton.addEventListener("click", () => {
+ this.openFilePicker();
+ });
+
+ // Preview area
+ contentEl.createEl("p", {
+ text: "Preview:",
+ cls: "thumbnail-preview-label",
+ });
+ this.preview = contentEl.createEl("img", { cls: "thumbnail-preview" });
+ this.preview.style.maxWidth = "100%";
+ this.preview.style.maxHeight = "150px";
+ this.preview.style.display = "none";
+
+ // Update preview when URL changes
+ this.inputEl.addEventListener("input", () => {
+ this.updatePreview();
+ });
+
+ // Buttons
+ const buttonContainer = contentEl.createDiv();
+ buttonContainer.style.display = "flex";
+ buttonContainer.style.justifyContent = "flex-end";
+ buttonContainer.style.gap = "10px";
+ buttonContainer.style.marginTop = "1em";
+
+ const cancelButton = buttonContainer.createEl("button", {
+ text: "Cancel",
+ });
+ const saveButton = buttonContainer.createEl("button", {
+ text: "Set Thumbnail",
+ cls: "mod-cta",
+ });
+
+ cancelButton.addEventListener("click", () => {
+ this.resolvePromise(null);
+ this.close();
+ });
+
+ saveButton.addEventListener("click", () => {
+ this.resolvePromise(this.inputEl.value);
+ this.close();
+ });
+ }
+
+ openFilePicker() {
+ const fileSuggestModal = new FileSuggestModal(this.app, (file) => {
+ if (file) {
+ this.inputEl.value = file.path;
+ this.updatePreview();
+ }
+ });
+ fileSuggestModal.open();
+ }
+
+ updatePreview() {
+ const url = this.inputEl.value;
+ if (url) {
+ try {
+ // For remote URLs
+ if (url.startsWith("http")) {
+ this.preview.src = url;
+ this.preview.style.display = "block";
+ }
+ // For local files
+ else {
+ this.preview.src =
+ this.app.vault.adapter.getResourcePath(url);
+ this.preview.style.display = "block";
+ }
+ } catch (e) {
+ this.preview.style.display = "none";
+ }
+ } else {
+ this.preview.style.display = "none";
+ }
+ }
+
+ onClose() {
+ const { contentEl } = this;
+ contentEl.empty();
+ }
+
+ openAndGetValue(): Promise {
+ return new Promise((resolve) => {
+ this.resolvePromise = resolve;
+ this.open();
+ });
+ }
+}
+
+// File selection modal for picking a file from the vault
+class FileSuggestModal extends SuggestModal {
+ private callback: (file: TFile | null) => void;
+
+ constructor(app: App, callback: (file: TFile | null) => void) {
+ super(app);
+ this.callback = callback;
+ }
+
+ getSuggestions(query: string): TFile[] {
+ const imageExtensions = [
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "bmp",
+ "svg",
+ "webp",
+ ];
+
+ // Get all image files from the vault
+ const files = this.app.vault
+ .getFiles()
+ .filter(
+ (file) =>
+ imageExtensions.includes(file.extension.toLowerCase()) &&
+ file.path.toLowerCase().includes(query.toLowerCase())
+ );
+
+ return files;
+ }
+
+ renderSuggestion(file: TFile, el: HTMLElement) {
+ el.createEl("div", { text: file.path });
+ }
+
+ onChooseSuggestion(file: TFile, evt: MouseEvent | KeyboardEvent) {
+ this.callback(file);
+ }
+
+ onClose() {
+ // If no selection was made, call the callback with null
+ if (this.callback) {
+ this.callback(null);
+ }
+ }
+}
+
export const refreshAllCanvasView = (app: App) => {
const cavasLeaves = app.workspace.getLeavesOfType("canvas");
if (!cavasLeaves || cavasLeaves.length === 0) return;
diff --git a/styles.css b/styles.css
index 259a70d..af8c49a 100644
--- a/styles.css
+++ b/styles.css
@@ -4,19 +4,35 @@
overflow: hidden;
- width: calc(100% * var(--zoom-multiplier));
-
min-width: 100%;
- height: calc(40px * var(--zoom-multiplier));
+ height: calc(30px * var(--zoom-multiplier));
/*max-height: 100px;*/
- min-height: 20px;
+ min-height: max(calc(30px * var(--zoom-multiplier)), 20px);
cursor: pointer;
display: flex;
align-items: center;
padding-left: var(--size-4-2);
gap: var(--size-4-3);
- margin-top: -1px;
+ margin-top: calc(30px * var(--zoom-multiplier) * -1);
+
+ transition: background-color 0.2s ease-in-out;
+}
+
+.canvas-node-container:has(.canvas-node-content > img)
+ .canvas-node-collapse-control {
+ position: absolute;
+}
+
+.canvas-node-container:not(:has(.canvas-node-content > img)):has(
+ .canvas-node-collapse-control
+ ) {
+ padding-top: calc(30px * var(--zoom-multiplier));
+}
+
+.canvas-node-container:has(.canvas-node-content > img)
+ .canvas-node-collapse-control {
+ margin-top: 0;
}
.canvas-node:not(.collapsed) .canvas-node-collapse-control {
@@ -31,7 +47,6 @@
background-color: rgb(var(--canvas-color));
}
-
.canvas-node:has(.canvas-node-collapse-control) {
width: fit-content;
height: fit-content;
@@ -41,7 +56,8 @@
/* pointer-events: unset;*/
/*}*/
-.canvas-node-group:has(.canvas-node-collapse-control) .canvas-node-collapse-control {
+.canvas-node-group:has(.canvas-node-collapse-control)
+ .canvas-node-collapse-control {
pointer-events: initial !important;
}
@@ -50,40 +66,224 @@
/*}*/
.canvas-node.collapsed {
- height: calc(40px * var(--zoom-multiplier)) !important;
+ height: calc(30px * var(--zoom-multiplier)) !important;
max-height: fit-content;
+ transition: height 0.2s ease-in-out, max-height 0.2s ease-in-out;
}
.canvas-node.collapsed .canvas-node-placeholder {
display: none;
}
-.group-nodes-collapsed, .group-edges-collapsed {
+.group-nodes-collapsed,
+.group-edges-collapsed {
display: none;
+ transition: opacity 0.2s ease-in-out;
+ opacity: 0;
}
.canvas-node.collapsed .canvas-node-content {
display: none;
+ transition: opacity 0.2s ease-in-out;
+ opacity: 0;
}
.collapsed-interaction {
- height: calc(40px * var(--zoom-multiplier)) !important;
+ height: calc(30px * var(--zoom-multiplier)) !important;
+ transition: height 0.2s ease-in-out;
}
-.canvas-node-collapse-control-icon, .canvas-node-type-icon {
+.canvas-node-collapse-control-icon,
+.canvas-node-type-icon {
display: flex;
}
-.canvas-node-collapse-control-icon svg, .canvas-node-type-icon svg {
+.canvas-node-collapse-control-icon svg,
+.canvas-node-type-icon svg {
height: calc(18px * var(--zoom-multiplier));
width: calc(18px * var(--zoom-multiplier));
}
-
.canvas-node-collapse-control + .canvas-node-content.media-embed img {
- height: 100%;
+ height: auto;
+ max-height: 100%;
+ object-fit: contain;
}
-.minimal-control-header .canvas-node-collapse-control-title, .minimal-control-header .canvas-node-type-icon {
+.minimal-control-header .canvas-node-collapse-control-title,
+.minimal-control-header .canvas-node-type-icon {
display: none;
}
+
+.canvas-node {
+ /* Add transition for height and max-height only */
+ transition: height 0.2s ease-in-out, max-height 0.2s ease-in-out;
+}
+
+.canvas-node-content {
+ /* Add transition for opacity */
+ transition: opacity 0.2s ease-in-out;
+ opacity: 1;
+}
+
+.canvas-node.collapsed .canvas-node-content {
+ opacity: 0;
+ /* Keep the display:none but add it with a delay so the opacity transition is visible */
+ transition: opacity 0.2s ease-in-out;
+}
+
+/* Add rotation animation to the collapse icon */
+.canvas-node-collapse-control-icon svg {
+ transition: transform 0.2s ease;
+}
+
+.canvas-node.collapsed .canvas-node-collapse-control-icon svg {
+ transform: rotate(-90deg);
+ transition: transform 0.2s ease;
+}
+
+/* Remove hover scale effect that causes node drift */
+/* .canvas-node:not(.collapsed):hover {
+ transform: scale(1.01);
+} */
+
+/* Animate the collapse control background */
+.canvas-node-collapse-control {
+ transition: background-color 0.2s ease-in-out;
+}
+
+/* Modify drawer animation to prevent drift */
+.canvas-node-group .group-nodes,
+.canvas-node-group .group-edges {
+ transition: opacity 0.2s ease-in-out, max-height 0.2s ease-in-out;
+ /* Remove transform transitions that cause drift */
+}
+
+.canvas-node-group.collapsed .group-nodes-collapsed,
+.canvas-node-group.collapsed .group-edges-collapsed {
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ /* Remove transform that causes drift */
+}
+
+/* Animation classes for transitions */
+.node-transitioning {
+ transition: opacity 0.2s ease-in-out, max-height 0.2s ease-in-out !important;
+ /* Remove transform transition that causes drift */
+}
+
+.animating {
+ overflow: hidden !important;
+}
+
+/* Simplified drawer animation to prevent drift */
+.canvas-node-group .group-nodes {
+ transition: max-height 0.2s ease-in-out, opacity 0.2s ease-in-out;
+ will-change: opacity, max-height;
+ /* Remove transform properties that cause drift */
+}
+
+.canvas-node-group.collapsed .group-nodes {
+ max-height: 0;
+ opacity: 0;
+ transition: max-height 0.2s ease-in-out, opacity 0.2s ease-in-out;
+ /* Remove transform that causes drift */
+}
+
+/* Animation for edges */
+.canvas-edge {
+ transition: opacity 0.2s ease-in-out;
+}
+
+.group-edges-collapsed {
+ transition: opacity 0.2s ease-in-out;
+ opacity: 0;
+}
+
+/* Remove bounce animation that causes drift */
+/* @keyframes bounceIn {
+ 0% {
+ transform: scale(0.95);
+ opacity: 0;
+ }
+ 70% {
+ transform: scale(1.02);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+.canvas-node:not(.collapsed):not(.animating) {
+ animation: bounceIn 0.4s ease-out;
+} */
+
+.collapse-handler.collapsed svg {
+ transform: rotate(-90deg);
+ transition: transform 0.2s ease-in-out;
+}
+
+.collapse-handler svg {
+ transform: rotate(0deg);
+ transition: transform 0.2s ease-in-out;
+}
+
+.canvas-node .canvas-node-collapse-control-thumbnail {
+ display: none;
+}
+
+/* Styles for thumbnails and aliases in collapsed state */
+.canvas-node.collapsed .canvas-node-collapse-control-thumbnail {
+ max-width: 100%;
+ max-height: 100px;
+ object-fit: contain;
+ margin: 5px auto;
+ display: block;
+ border-radius: 4px;
+ position: absolute;
+ bottom: calc(27px * var(--zoom-multiplier));
+}
+
+.canvas-node.collapsed .canvas-node-collapse-control-alias {
+ display: block;
+ font-weight: bold;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ color: var(--text-normal);
+}
+
+/* Adjust the header to accommodate thumbnails and aliases */
+.canvas-node.collapsed .canvas-node-collapse-control {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+}
+
+/* Make sure the header elements are properly spaced in collapsed mode */
+.canvas-node .canvas-node-collapse-control-icon,
+.canvas-node .canvas-node-type-icon {
+ margin-bottom: 4px;
+}
+
+body.hide-default-node-title
+ .canvas-node:has(.canvas-node-collapse-control)
+ .canvas-node-label {
+ display: none;
+}
+
+.collapsed-node-hidden {
+ display: none !important;
+}
+
+
+span.canvas-node-collapse-control-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+}
diff --git a/versions.json b/versions.json
index 8a2e1b6..a21908e 100644
--- a/versions.json
+++ b/versions.json
@@ -2,5 +2,6 @@
"1.0.0": "1.1.0",
"1.0.1": "1.1.0",
"1.1.0": "1.1.0",
- "1.1.1": "1.1.0"
+ "1.1.1": "1.1.0",
+ "2.0.0": "1.1.0"
}
\ No newline at end of file