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