diff --git a/package-lock.json b/package-lock.json
index fa95ef8..3c9e35b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,8 @@
"license": "MIT",
"dependencies": {
"@types/cytoscape": "^3.21.9",
- "cytoscape": "^3.33.4"
+ "cytoscape": "^3.33.4",
+ "cytoscape-node-html-label": "^1.2.2"
},
"devDependencies": {
"@types/node": "^20.11.0",
@@ -518,6 +519,21 @@
"node": ">=0.10"
}
},
+ "node_modules/cytoscape-node-html-label": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cytoscape-node-html-label/-/cytoscape-node-html-label-1.2.2.tgz",
+ "integrity": "sha512-oUVwrlsIlaJJ8QrQFSMdv3uXVXPg6tMH/Tfofr8JuZIovqI4fPqBi6sQgCMcVpS6k9Td0TTjowBsNRw32CESWg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/cytoscape": "^3.1.0",
+ "cytoscape": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/cytoscape": {
+ "optional": true
+ }
+ }
+ },
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
diff --git a/package.json b/package.json
index 719dd9d..74e635a 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
},
"dependencies": {
"@types/cytoscape": "^3.21.9",
- "cytoscape": "^3.33.4"
+ "cytoscape": "^3.33.4",
+ "cytoscape-node-html-label": "^1.2.2"
}
}
diff --git a/src/views/GraphView.ts b/src/views/GraphView.ts
index da4fa11..12de665 100644
--- a/src/views/GraphView.ts
+++ b/src/views/GraphView.ts
@@ -1,6 +1,40 @@
import { App, Notice, WorkspaceLeaf, normalizePath } from "obsidian";
import cytoscape, { Core, NodeSingular } from "cytoscape";
+// @ts-ignore — no exported types
+import nodeHtmlLabel from "cytoscape-node-html-label";
import { CORE_FILE } from "../const";
+
+let htmlLabelRegistered = false;
+function ensureHtmlLabel(): void {
+ if (htmlLabelRegistered) return;
+ cytoscape.use(nodeHtmlLabel as never);
+ htmlLabelRegistered = true;
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function nodeTemplate(data: { name: string; features: string[] }): string {
+ const name = escapeHtml(data.name);
+ const badges = data.features
+ .map(
+ (f) =>
+ `${escapeHtml(f)}`,
+ )
+ .join("");
+ return `
+
+
${name}
+ ${data.features.length ? `
${badges}
` : ""}
+
+ `;
+}
import {
projectPath,
collectionPath,
@@ -9,6 +43,7 @@ import {
listCollectionFeatures,
createCollection,
renameCollection,
+ createFeature,
deleteRecursive,
} from "../fs";
import { menu, openMarkdown } from "../ui";
@@ -37,6 +72,7 @@ function validateName(name: string, taken: string[], current?: string): string |
export class GraphView {
private cy: Core | null = null;
private host: HTMLElement | null = null;
+ private docListeners: Array<() => void> = [];
constructor(
private app: App,
@@ -46,6 +82,8 @@ export class GraphView {
) {}
destroy(): void {
+ for (const off of this.docListeners) off();
+ this.docListeners = [];
if (this.cy) {
this.cy.destroy();
this.cy = null;
@@ -55,6 +93,7 @@ export class GraphView {
async render(parent: HTMLElement): Promise {
this.destroy();
+ ensureHtmlLabel();
const container = parent.createDiv({ cls: "pk-graph" });
this.host = container;
@@ -63,11 +102,8 @@ export class GraphView {
const elements = this.buildElements(data, collections);
const styles = getComputedStyle(parent);
- const textColor = styles.getPropertyValue("--text-normal").trim() || "#222";
- const borderColor = styles.getPropertyValue("--background-modifier-border").trim() || "#ccc";
const edgeColor = styles.getPropertyValue("--text-muted").trim() || "#888";
const accent = styles.getPropertyValue("--interactive-accent").trim() || "#7b6cd9";
- const bgNode = styles.getPropertyValue("--background-primary").trim() || "#fff";
this.cy = cytoscape({
container,
@@ -77,22 +113,15 @@ export class GraphView {
userPanningEnabled: true,
style: [
{
- selector: "node",
+ selector: "node[name]",
style: {
- label: "data(label)",
- "text-valign": "center",
- "text-halign": "center",
- "text-wrap": "wrap",
- "text-max-width": "160px",
- "background-color": bgNode,
- "border-color": borderColor,
- "border-width": 1,
- color: textColor,
- "font-size": 12,
+ "background-color": "rgba(0,0,0,0)",
+ "background-opacity": 0,
+ "border-width": 0,
shape: "round-rectangle",
- width: "label" as unknown as number,
- height: "label" as unknown as number,
- padding: "10px",
+ label: "",
+ width: "data(w)" as unknown as number,
+ height: "data(h)" as unknown as number,
},
},
{
@@ -100,9 +129,13 @@ export class GraphView {
style: {
shape: "ellipse",
"background-color": accent,
+ "background-opacity": 1,
color: "#fff",
"border-width": 0,
"font-weight": "bold",
+ label: "data(label)",
+ "text-valign": "center",
+ "text-halign": "center",
width: 80,
height: 80,
},
@@ -112,6 +145,7 @@ export class GraphView {
style: {
"border-color": accent,
"border-width": 3,
+ "background-opacity": 0.05,
},
},
{
@@ -129,9 +163,9 @@ export class GraphView {
name: "concentric",
concentric: (node) => -((node.data("depth") as number) ?? 0),
levelWidth: () => 1,
- minNodeSpacing: 60,
+ minNodeSpacing: 80,
// @ts-ignore — spacingFactor exists in concentric layout
- spacingFactor: 1.4,
+ spacingFactor: 1.6,
fit: true,
padding: 30,
startAngle: -Math.PI / 2,
@@ -139,6 +173,18 @@ export class GraphView {
},
});
+ // @ts-ignore — added by cytoscape-node-html-label
+ this.cy.nodeHtmlLabel([
+ {
+ query: "node[name]",
+ halign: "center",
+ valign: "center",
+ halignBox: "center",
+ valignBox: "center",
+ tpl: (d: { name: string; features: string[] }) => nodeTemplate(d),
+ },
+ ]);
+
this.bindHandlers();
}
@@ -161,13 +207,11 @@ export class GraphView {
if (visited.has(child)) continue;
visited.add(child);
const id = `n_${child}`;
+ const features = listCollectionFeatures(this.app, this.project, child)
+ .map((f) => f.basename);
+ const { w, h } = this.nodeSize(features.length);
els.push({
- data: {
- id,
- name: child,
- label: this.nodeLabel(child),
- depth,
- },
+ data: { id, name: child, depth, features, w, h },
});
els.push({ data: { source: parentId, target: id } });
walk(child, id, depth + 1);
@@ -177,11 +221,11 @@ export class GraphView {
return els;
}
- private nodeLabel(collection: string): string {
- const features = listCollectionFeatures(this.app, this.project, collection)
- .map((f) => f.basename);
- if (features.length === 0) return collection;
- return `${collection}\n${features.map((f) => `• ${f}`).join(" ")}`;
+ private nodeSize(featureCount: number): { w: number; h: number } {
+ const w = 180;
+ const rows = Math.max(1, Math.ceil(featureCount / 2));
+ const h = 40 + rows * 26;
+ return { w, h };
}
private bindHandlers(): void {
@@ -190,31 +234,9 @@ export class GraphView {
cy.on("tap", "node", (ev) => {
const node = ev.target as NodeSingular;
- if (node.id() === ROOT_ID) {
- const corePath = normalizePath(`${projectPath(this.project)}/${CORE_FILE}`);
- void openMarkdown(this.app, corePath, this.leaf);
- return;
- }
- const name = node.data("name") as string;
- void openMarkdown(this.app, collectionMdPath(this.project, name), this.leaf);
- });
-
- cy.on("cxttap", "node", (ev) => {
- const node = ev.target as NodeSingular;
- const oe = ev.originalEvent as MouseEvent;
- oe.preventDefault();
- if (node.id() === ROOT_ID) {
- menu(oe, [
- { title: "Node erstellen", icon: "plus", onClick: () => this.openCreateChild(null) },
- ]);
- return;
- }
- const name = node.data("name") as string;
- menu(oe, [
- { title: "Child-Node erstellen", icon: "plus", onClick: () => this.openCreateChild(name) },
- { title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(name) },
- { title: "Löschen", icon: "trash", onClick: () => this.deleteNode(name) },
- ]);
+ if (node.id() !== ROOT_ID) return;
+ const corePath = normalizePath(`${projectPath(this.project)}/${CORE_FILE}`);
+ void openMarkdown(this.app, corePath, this.leaf);
});
cy.on("cxttap", (ev) => {
@@ -226,74 +248,153 @@ export class GraphView {
]);
});
- let grabbed: NodeSingular | null = null;
- let grabStart: { x: number; y: number } | null = null;
-
- cy.on("grab", "node", (ev) => {
- const n = ev.target as NodeSingular;
- if (n.id() === ROOT_ID) return;
- grabbed = n;
- const pos = n.position();
- grabStart = { x: pos.x, y: pos.y };
- });
-
- cy.on("drag", "node", (ev) => {
- if (!grabbed || ev.target.id() !== grabbed.id()) return;
- const hovered = this.nodeAtPoint(grabbed.position(), grabbed.id());
- cy.nodes(".pk-drop-target").removeClass("pk-drop-target");
- if (hovered) hovered.addClass("pk-drop-target");
- });
-
- cy.on("free", "node", (ev) => {
- if (!grabbed || ev.target.id() !== grabbed.id()) return;
- const node = grabbed;
- const start = grabStart;
- grabbed = null;
- grabStart = null;
- cy.nodes(".pk-drop-target").removeClass("pk-drop-target");
-
- const target = this.nodeAtPoint(node.position(), node.id());
- if (!target) {
- if (start) node.position(start);
- return;
- }
- const childName = node.data("name") as string;
- const newParent = target.id() === ROOT_ID ? null : (target.data("name") as string);
- void this.handleReparent(childName, newParent, start);
- });
+ if (this.host) this.bindHtmlHandlers(this.host);
}
- private nodeAtPoint(pos: { x: number; y: number }, excludeId: string): NodeSingular | null {
- if (!this.cy) return null;
- let hit: NodeSingular | null = null;
- this.cy.nodes().forEach((n) => {
- if (n.id() === excludeId) return;
- const bb = n.boundingBox({});
- if (pos.x >= bb.x1 && pos.x <= bb.x2 && pos.y >= bb.y1 && pos.y <= bb.y2) {
- hit = n;
- }
+ private bindHtmlHandlers(host: HTMLElement): void {
+ host.addEventListener("contextmenu", (ev) => {
+ const target = ev.target as HTMLElement;
+ const nodeEl = target.closest(".pk-graph-node");
+ if (!nodeEl) return;
+ const name = nodeEl.dataset.node;
+ if (!name) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ menu(ev as MouseEvent, [
+ { title: "Feature erstellen", icon: "plus", onClick: () => this.openCreateFeature(name) },
+ { title: "Child-Node erstellen", icon: "plus", onClick: () => this.openCreateChild(name) },
+ { title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(name) },
+ { title: "Löschen", icon: "trash", onClick: () => this.deleteNode(name) },
+ ]);
});
- return hit;
+
+ const THRESHOLD = 6;
+ let dragName: string | null = null;
+ let sourceEl: HTMLElement | null = null;
+ let downPos: { x: number; y: number } | null = null;
+ let dragging = false;
+
+ const clearDropClasses = () => {
+ host.querySelectorAll(".pk-drop-target").forEach((el) =>
+ el.removeClass("pk-drop-target"),
+ );
+ host.removeClass("pk-drop-target-bg");
+ };
+
+ const resetDrag = () => {
+ if (sourceEl) sourceEl.removeClass("pk-dragging");
+ clearDropClasses();
+ dragName = null;
+ sourceEl = null;
+ downPos = null;
+ dragging = false;
+ };
+
+ host.addEventListener("mousedown", (ev) => {
+ if (ev.button !== 0) return;
+ const nodeEl = (ev.target as HTMLElement).closest(".pk-graph-node");
+ if (!nodeEl) return;
+ dragName = nodeEl.dataset.node ?? null;
+ sourceEl = nodeEl;
+ downPos = { x: ev.clientX, y: ev.clientY };
+ dragging = false;
+ });
+
+ const onMouseMove = (ev: MouseEvent) => {
+ if (!dragName || !downPos) return;
+ const dx = ev.clientX - downPos.x;
+ const dy = ev.clientY - downPos.y;
+ if (!dragging) {
+ if (Math.hypot(dx, dy) < THRESHOLD) return;
+ dragging = true;
+ sourceEl?.addClass("pk-dragging");
+ }
+ clearDropClasses();
+ const el = document.elementFromPoint(ev.clientX, ev.clientY);
+ const hit = el?.closest(".pk-graph-node");
+ if (hit && hit !== sourceEl) {
+ hit.addClass("pk-drop-target");
+ } else if (host.contains(el as Node) && !hit) {
+ host.addClass("pk-drop-target-bg");
+ }
+ };
+
+ const onMouseUp = (ev: MouseEvent) => {
+ if (!dragName) return;
+
+ if (!dragging) {
+ const el = document.elementFromPoint(ev.clientX, ev.clientY);
+ const upNode = el?.closest(".pk-graph-node");
+ const upName = upNode?.dataset.node ?? null;
+ if (upName && upName === dragName) {
+ const featEl = el?.closest("[data-action='open-feature']");
+ if (featEl?.dataset.feature) {
+ void openMarkdown(
+ this.app,
+ normalizePath(`${collectionPath(this.project, upName)}/${featEl.dataset.feature}.md`),
+ this.leaf,
+ );
+ } else {
+ void openMarkdown(this.app, collectionMdPath(this.project, upName), this.leaf);
+ }
+ }
+ resetDrag();
+ return;
+ }
+
+ const source = dragName;
+ const el = document.elementFromPoint(ev.clientX, ev.clientY);
+ const targetEl = el?.closest(".pk-graph-node");
+ const targetInHost = host.contains(el as Node);
+ const targetName = targetEl && targetEl !== sourceEl
+ ? (targetEl.dataset.node ?? null)
+ : null;
+ const validDrop = !!targetEl || targetInHost;
+ resetDrag();
+ if (!validDrop) return;
+ if (targetName === source) return;
+ void this.handleReparent(source, targetName);
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ this.docListeners.push(
+ () => document.removeEventListener("mousemove", onMouseMove),
+ () => document.removeEventListener("mouseup", onMouseUp),
+ );
}
private async handleReparent(
child: string,
newParent: string | null,
- start: { x: number; y: number } | null,
): Promise {
const data = await readGraph(this.app, this.project);
const next = setParent(data, child, newParent);
- if (JSON.stringify(next) === JSON.stringify(data)) {
- if (start && this.cy) {
- const n = this.cy.getElementById(`n_${child}`);
- if (n.nonempty()) n.position(start);
- }
- return;
- }
+ if (JSON.stringify(next) === JSON.stringify(data)) return;
await writeGraph(this.app, this.project, next);
this.onChange();
}
+ private openCreateFeature(collectionName: string): void {
+ const taken = listCollectionFeatures(this.app, this.project, collectionName).map(
+ (f) => f.basename,
+ );
+ new NameModal(this.app, {
+ title: "Neues Feature",
+ label: "Feature-Name",
+ cta: "Erstellen",
+ validate: (n) => validateName(n, taken),
+ onSubmit: async (name) => {
+ try {
+ await createFeature(this.app, this.project, collectionName, name);
+ this.onChange();
+ } catch (e) {
+ new Notice(`Fehler: ${(e as Error).message}`);
+ }
+ },
+ }).open();
+ }
+
private openCreateChild(parent: string | null): void {
const taken = listFolders(this.app, projectPath(this.project)).map((c) => c.name);
new NameModal(this.app, {
diff --git a/styles.css b/styles.css
index 92cd366..acd631e 100644
--- a/styles.css
+++ b/styles.css
@@ -213,6 +213,56 @@
color: var(--text-normal);
}
+/* Graph: HTML-Node */
+.pk-graph-node {
+ box-sizing: border-box;
+ width: 160px;
+ padding: 8px 10px;
+ background: var(--background-secondary);
+ border: 1px solid var(--background-modifier-border);
+ border-radius: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ align-items: stretch;
+ text-align: center;
+ color: var(--text-normal);
+ overflow-wrap: anywhere;
+ font-size: 0.85em;
+ user-select: none;
+}
+
+.pk-graph-node strong {
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.pk-graph-node strong:hover {
+ color: var(--text-accent);
+}
+
+.pk-graph-node .pk-badges {
+ justify-content: center;
+}
+
+.pk-graph-node .pk-badge {
+ cursor: pointer;
+}
+
+.pk-graph-node.pk-drop-target {
+ border-color: var(--interactive-accent);
+ border-width: 2px;
+}
+
+.pk-graph-node.pk-dragging {
+ opacity: 0.4;
+}
+
+.pk-graph.pk-drop-target-bg {
+ outline: 2px dashed var(--interactive-accent);
+ outline-offset: -8px;
+}
+
/* Collection-Card Badges */
.pk-badges {
display: flex;