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",
"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",

View File

@@ -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"
}
}

View File

@@ -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, "&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 {
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<void> {
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<HTMLElement>(".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<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;
});
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<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 onMouseUp = (ev: MouseEvent) => {
if (!dragName) return;
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;
}
const source = dragName;
const el = document.elementFromPoint(ev.clientX, ev.clientY);
const targetEl = el?.closest<HTMLElement>(".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<void> {
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, {

View File

@@ -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;