From 9bbe7ae20a8c99c0352238ce677a94c4059810db Mon Sep 17 00:00:00 2001 From: team3 Date: Sat, 23 May 2026 01:23:18 +0200 Subject: [PATCH] update --- package-lock.json | 18 ++- package.json | 3 +- src/views/GraphView.ts | 317 +++++++++++++++++++++++++++-------------- styles.css | 50 +++++++ 4 files changed, 278 insertions(+), 110 deletions(-) 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;