update
This commit is contained in:
18
package-lock.json
generated
18
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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, {
|
||||
|
||||
50
styles.css
50
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;
|
||||
|
||||
Reference in New Issue
Block a user