This commit is contained in:
team3
2026-05-23 01:23:18 +02:00
parent ea43250974
commit 9bbe7ae20a
4 changed files with 278 additions and 110 deletions

18
package-lock.json generated
View File

@@ -10,7 +10,8 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/cytoscape": "^3.21.9", "@types/cytoscape": "^3.21.9",
"cytoscape": "^3.33.4" "cytoscape": "^3.33.4",
"cytoscape-node-html-label": "^1.2.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
@@ -518,6 +519,21 @@
"node": ">=0.10" "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": { "node_modules/esbuild": {
"version": "0.20.2", "version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",

View File

@@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@types/cytoscape": "^3.21.9", "@types/cytoscape": "^3.21.9",
"cytoscape": "^3.33.4" "cytoscape": "^3.33.4",
"cytoscape-node-html-label": "^1.2.2"
} }
} }

View File

@@ -1,6 +1,40 @@
import { App, Notice, WorkspaceLeaf, normalizePath } from "obsidian"; import { App, Notice, WorkspaceLeaf, normalizePath } from "obsidian";
import cytoscape, { Core, NodeSingular } from "cytoscape"; import cytoscape, { Core, NodeSingular } from "cytoscape";
// @ts-ignore — no exported types
import nodeHtmlLabel from "cytoscape-node-html-label";
import { CORE_FILE } from "../const"; 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function nodeTemplate(data: { name: string; features: string[] }): string {
const name = escapeHtml(data.name);
const badges = data.features
.map(
(f) =>
`<span class="pk-badge" data-action="open-feature" data-feature="${escapeHtml(f)}">${escapeHtml(f)}</span>`,
)
.join("");
return `
<div class="pk-graph-node" data-node="${name}">
<strong data-action="open-collection">${name}</strong>
${data.features.length ? `<div class="pk-badges">${badges}</div>` : ""}
</div>
`;
}
import { import {
projectPath, projectPath,
collectionPath, collectionPath,
@@ -9,6 +43,7 @@ import {
listCollectionFeatures, listCollectionFeatures,
createCollection, createCollection,
renameCollection, renameCollection,
createFeature,
deleteRecursive, deleteRecursive,
} from "../fs"; } from "../fs";
import { menu, openMarkdown } from "../ui"; import { menu, openMarkdown } from "../ui";
@@ -37,6 +72,7 @@ function validateName(name: string, taken: string[], current?: string): string |
export class GraphView { export class GraphView {
private cy: Core | null = null; private cy: Core | null = null;
private host: HTMLElement | null = null; private host: HTMLElement | null = null;
private docListeners: Array<() => void> = [];
constructor( constructor(
private app: App, private app: App,
@@ -46,6 +82,8 @@ export class GraphView {
) {} ) {}
destroy(): void { destroy(): void {
for (const off of this.docListeners) off();
this.docListeners = [];
if (this.cy) { if (this.cy) {
this.cy.destroy(); this.cy.destroy();
this.cy = null; this.cy = null;
@@ -55,6 +93,7 @@ export class GraphView {
async render(parent: HTMLElement): Promise<void> { async render(parent: HTMLElement): Promise<void> {
this.destroy(); this.destroy();
ensureHtmlLabel();
const container = parent.createDiv({ cls: "pk-graph" }); const container = parent.createDiv({ cls: "pk-graph" });
this.host = container; this.host = container;
@@ -63,11 +102,8 @@ export class GraphView {
const elements = this.buildElements(data, collections); const elements = this.buildElements(data, collections);
const styles = getComputedStyle(parent); 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 edgeColor = styles.getPropertyValue("--text-muted").trim() || "#888";
const accent = styles.getPropertyValue("--interactive-accent").trim() || "#7b6cd9"; const accent = styles.getPropertyValue("--interactive-accent").trim() || "#7b6cd9";
const bgNode = styles.getPropertyValue("--background-primary").trim() || "#fff";
this.cy = cytoscape({ this.cy = cytoscape({
container, container,
@@ -77,22 +113,15 @@ export class GraphView {
userPanningEnabled: true, userPanningEnabled: true,
style: [ style: [
{ {
selector: "node", selector: "node[name]",
style: { style: {
label: "data(label)", "background-color": "rgba(0,0,0,0)",
"text-valign": "center", "background-opacity": 0,
"text-halign": "center", "border-width": 0,
"text-wrap": "wrap",
"text-max-width": "160px",
"background-color": bgNode,
"border-color": borderColor,
"border-width": 1,
color: textColor,
"font-size": 12,
shape: "round-rectangle", shape: "round-rectangle",
width: "label" as unknown as number, label: "",
height: "label" as unknown as number, width: "data(w)" as unknown as number,
padding: "10px", height: "data(h)" as unknown as number,
}, },
}, },
{ {
@@ -100,9 +129,13 @@ export class GraphView {
style: { style: {
shape: "ellipse", shape: "ellipse",
"background-color": accent, "background-color": accent,
"background-opacity": 1,
color: "#fff", color: "#fff",
"border-width": 0, "border-width": 0,
"font-weight": "bold", "font-weight": "bold",
label: "data(label)",
"text-valign": "center",
"text-halign": "center",
width: 80, width: 80,
height: 80, height: 80,
}, },
@@ -112,6 +145,7 @@ export class GraphView {
style: { style: {
"border-color": accent, "border-color": accent,
"border-width": 3, "border-width": 3,
"background-opacity": 0.05,
}, },
}, },
{ {
@@ -129,9 +163,9 @@ export class GraphView {
name: "concentric", name: "concentric",
concentric: (node) => -((node.data("depth") as number) ?? 0), concentric: (node) => -((node.data("depth") as number) ?? 0),
levelWidth: () => 1, levelWidth: () => 1,
minNodeSpacing: 60, minNodeSpacing: 80,
// @ts-ignore — spacingFactor exists in concentric layout // @ts-ignore — spacingFactor exists in concentric layout
spacingFactor: 1.4, spacingFactor: 1.6,
fit: true, fit: true,
padding: 30, padding: 30,
startAngle: -Math.PI / 2, 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(); this.bindHandlers();
} }
@@ -161,13 +207,11 @@ export class GraphView {
if (visited.has(child)) continue; if (visited.has(child)) continue;
visited.add(child); visited.add(child);
const id = `n_${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({ els.push({
data: { data: { id, name: child, depth, features, w, h },
id,
name: child,
label: this.nodeLabel(child),
depth,
},
}); });
els.push({ data: { source: parentId, target: id } }); els.push({ data: { source: parentId, target: id } });
walk(child, id, depth + 1); walk(child, id, depth + 1);
@@ -177,11 +221,11 @@ export class GraphView {
return els; return els;
} }
private nodeLabel(collection: string): string { private nodeSize(featureCount: number): { w: number; h: number } {
const features = listCollectionFeatures(this.app, this.project, collection) const w = 180;
.map((f) => f.basename); const rows = Math.max(1, Math.ceil(featureCount / 2));
if (features.length === 0) return collection; const h = 40 + rows * 26;
return `${collection}\n${features.map((f) => `${f}`).join(" ")}`; return { w, h };
} }
private bindHandlers(): void { private bindHandlers(): void {
@@ -190,31 +234,9 @@ export class GraphView {
cy.on("tap", "node", (ev) => { cy.on("tap", "node", (ev) => {
const node = ev.target as NodeSingular; const node = ev.target as NodeSingular;
if (node.id() === ROOT_ID) { if (node.id() !== ROOT_ID) return;
const corePath = normalizePath(`${projectPath(this.project)}/${CORE_FILE}`); const corePath = normalizePath(`${projectPath(this.project)}/${CORE_FILE}`);
void openMarkdown(this.app, corePath, this.leaf); 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) },
]);
}); });
cy.on("cxttap", (ev) => { cy.on("cxttap", (ev) => {
@@ -226,74 +248,153 @@ export class GraphView {
]); ]);
}); });
let grabbed: NodeSingular | null = null; if (this.host) this.bindHtmlHandlers(this.host);
let grabStart: { x: number; y: number } | null = null; }
cy.on("grab", "node", (ev) => { private bindHtmlHandlers(host: HTMLElement): void {
const n = ev.target as NodeSingular; host.addEventListener("contextmenu", (ev) => {
if (n.id() === ROOT_ID) return; const target = ev.target as HTMLElement;
grabbed = n; const nodeEl = target.closest<HTMLElement>(".pk-graph-node");
const pos = n.position(); if (!nodeEl) return;
grabStart = { x: pos.x, y: pos.y }; 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) },
]);
}); });
cy.on("drag", "node", (ev) => { const THRESHOLD = 6;
if (!grabbed || ev.target.id() !== grabbed.id()) return; let dragName: string | null = null;
const hovered = this.nodeAtPoint(grabbed.position(), grabbed.id()); let sourceEl: HTMLElement | null = null;
cy.nodes(".pk-drop-target").removeClass("pk-drop-target"); let downPos: { x: number; y: number } | null = null;
if (hovered) hovered.addClass("pk-drop-target"); let dragging = false;
const clearDropClasses = () => {
host.querySelectorAll<HTMLElement>(".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<HTMLElement>(".pk-graph-node");
if (!nodeEl) return;
dragName = nodeEl.dataset.node ?? null;
sourceEl = nodeEl;
downPos = { x: ev.clientX, y: ev.clientY };
dragging = false;
}); });
cy.on("free", "node", (ev) => { const onMouseMove = (ev: MouseEvent) => {
if (!grabbed || ev.target.id() !== grabbed.id()) return; if (!dragName || !downPos) return;
const node = grabbed; const dx = ev.clientX - downPos.x;
const start = grabStart; const dy = ev.clientY - downPos.y;
grabbed = null; if (!dragging) {
grabStart = null; if (Math.hypot(dx, dy) < THRESHOLD) return;
cy.nodes(".pk-drop-target").removeClass("pk-drop-target"); dragging = true;
sourceEl?.addClass("pk-dragging");
}
clearDropClasses();
const el = document.elementFromPoint(ev.clientX, ev.clientY);
const hit = el?.closest<HTMLElement>(".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 target = this.nodeAtPoint(node.position(), node.id()); const onMouseUp = (ev: MouseEvent) => {
if (!target) { if (!dragName) return;
if (start) node.position(start);
if (!dragging) {
const el = document.elementFromPoint(ev.clientX, ev.clientY);
const upNode = el?.closest<HTMLElement>(".pk-graph-node");
const upName = upNode?.dataset.node ?? null;
if (upName && upName === dragName) {
const featEl = el?.closest<HTMLElement>("[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; 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);
});
}
private nodeAtPoint(pos: { x: number; y: number }, excludeId: string): NodeSingular | null { const source = dragName;
if (!this.cy) return null; const el = document.elementFromPoint(ev.clientX, ev.clientY);
let hit: NodeSingular | null = null; const targetEl = el?.closest<HTMLElement>(".pk-graph-node");
this.cy.nodes().forEach((n) => { const targetInHost = host.contains(el as Node);
if (n.id() === excludeId) return; const targetName = targetEl && targetEl !== sourceEl
const bb = n.boundingBox({}); ? (targetEl.dataset.node ?? null)
if (pos.x >= bb.x1 && pos.x <= bb.x2 && pos.y >= bb.y1 && pos.y <= bb.y2) { : null;
hit = n; const validDrop = !!targetEl || targetInHost;
} resetDrag();
}); if (!validDrop) return;
return hit; 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( private async handleReparent(
child: string, child: string,
newParent: string | null, newParent: string | null,
start: { x: number; y: number } | null,
): Promise<void> { ): Promise<void> {
const data = await readGraph(this.app, this.project); const data = await readGraph(this.app, this.project);
const next = setParent(data, child, newParent); const next = setParent(data, child, newParent);
if (JSON.stringify(next) === JSON.stringify(data)) { if (JSON.stringify(next) === JSON.stringify(data)) return;
if (start && this.cy) {
const n = this.cy.getElementById(`n_${child}`);
if (n.nonempty()) n.position(start);
}
return;
}
await writeGraph(this.app, this.project, next); await writeGraph(this.app, this.project, next);
this.onChange(); 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 { private openCreateChild(parent: string | null): void {
const taken = listFolders(this.app, projectPath(this.project)).map((c) => c.name); const taken = listFolders(this.app, projectPath(this.project)).map((c) => c.name);
new NameModal(this.app, { new NameModal(this.app, {

View File

@@ -213,6 +213,56 @@
color: var(--text-normal); 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 */ /* Collection-Card Badges */
.pk-badges { .pk-badges {
display: flex; display: flex;