init
This commit is contained in:
11
src/const.ts
Normal file
11
src/const.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const PROJECTS_ROOT = "";
|
||||
|
||||
export const VIEW_TYPE_PROJECT_VIEW = "obsidian-graph-projects";
|
||||
export const VIEW_TYPE_PROJECT_DETAILS_VIEW = "obsidian-graph-overview";
|
||||
|
||||
export const RIBBON_ICON = "layout-grid";
|
||||
|
||||
export const CORE_FILE = "_core.md";
|
||||
export const DESCRIPTION_FILE = "_description.md";
|
||||
|
||||
export const PROJECT_FILES = [CORE_FILE, DESCRIPTION_FILE] as const;
|
||||
160
src/fs.ts
Normal file
160
src/fs.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { App, TFile, TFolder, normalizePath } from "obsidian";
|
||||
import { PROJECTS_ROOT, PROJECT_FILES } from "./const";
|
||||
|
||||
export function projectsPath(): string {
|
||||
return PROJECTS_ROOT;
|
||||
}
|
||||
|
||||
export function projectPath(name: string): string {
|
||||
return normalizePath(`${PROJECTS_ROOT}/${name}`);
|
||||
}
|
||||
|
||||
export function nodeMdPath(folderPath: string): string {
|
||||
const p = normalizePath(folderPath);
|
||||
const name = p.split("/").pop() ?? p;
|
||||
return normalizePath(`${p}/${name}.md`);
|
||||
}
|
||||
|
||||
export function isNode(app: App, folderPath: string): boolean {
|
||||
const md = app.vault.getAbstractFileByPath(nodeMdPath(folderPath));
|
||||
return md instanceof TFile;
|
||||
}
|
||||
|
||||
export async function ensureFolder(app: App, path: string): Promise<void> {
|
||||
if (!path) return;
|
||||
const p = normalizePath(path);
|
||||
const exists = app.vault.getAbstractFileByPath(p);
|
||||
if (!exists) {
|
||||
await app.vault.createFolder(p);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFile(app: App, path: string, content = ""): Promise<TFile> {
|
||||
const p = normalizePath(path);
|
||||
const existing = app.vault.getAbstractFileByPath(p);
|
||||
if (existing instanceof TFile) return existing;
|
||||
return await app.vault.create(p, content);
|
||||
}
|
||||
|
||||
export function listFolders(app: App, path: string): TFolder[] {
|
||||
const folder = path ? app.vault.getAbstractFileByPath(normalizePath(path)) : app.vault.getRoot();
|
||||
if (!(folder instanceof TFolder)) return [];
|
||||
return folder.children
|
||||
.filter((c): c is TFolder => c instanceof TFolder)
|
||||
.filter((c) => !c.name.startsWith("_") || c.name.startsWith("__"))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function listMarkdownFiles(app: App, path: string, exclude: string[] = []): TFile[] {
|
||||
const folder = path ? app.vault.getAbstractFileByPath(normalizePath(path)) : app.vault.getRoot();
|
||||
if (!(folder instanceof TFolder)) return [];
|
||||
return folder.children
|
||||
.filter((c): c is TFile => c instanceof TFile && c.extension === "md")
|
||||
.filter((f) => !exclude.includes(f.name))
|
||||
.filter((f) => !f.basename.startsWith("_") || f.basename.startsWith("__"))
|
||||
.sort((a, b) => a.basename.localeCompare(b.basename));
|
||||
}
|
||||
|
||||
export async function readFile(app: App, path: string): Promise<string> {
|
||||
const p = normalizePath(path);
|
||||
const f = app.vault.getAbstractFileByPath(p);
|
||||
if (!(f instanceof TFile)) return "";
|
||||
return await app.vault.read(f);
|
||||
}
|
||||
|
||||
export async function deleteRecursive(app: App, path: string): Promise<void> {
|
||||
const p = normalizePath(path);
|
||||
const f = app.vault.getAbstractFileByPath(p);
|
||||
if (!f) return;
|
||||
await app.vault.delete(f, true);
|
||||
}
|
||||
|
||||
export async function rename(app: App, oldPath: string, newPath: string): Promise<void> {
|
||||
const f = app.vault.getAbstractFileByPath(normalizePath(oldPath));
|
||||
if (!f) return;
|
||||
await app.fileManager.renameFile(f, normalizePath(newPath));
|
||||
}
|
||||
|
||||
export async function createProject(app: App, name: string): Promise<void> {
|
||||
const root = projectPath(name);
|
||||
await ensureFolder(app, projectsPath());
|
||||
await ensureFolder(app, root);
|
||||
for (const file of PROJECT_FILES) {
|
||||
await ensureFile(app, normalizePath(`${root}/${file}`), "");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNode(
|
||||
app: App,
|
||||
parentFolderPath: string,
|
||||
name: string,
|
||||
): Promise<TFile> {
|
||||
const folder = normalizePath(`${parentFolderPath}/${name}`);
|
||||
await ensureFolder(app, folder);
|
||||
return await ensureFile(app, nodeMdPath(folder), "");
|
||||
}
|
||||
|
||||
export async function renameNode(
|
||||
app: App,
|
||||
folderPath: string,
|
||||
newName: string,
|
||||
): Promise<void> {
|
||||
const p = normalizePath(folderPath);
|
||||
const parent = p.substring(0, p.lastIndexOf("/"));
|
||||
const oldName = p.split("/").pop() ?? "";
|
||||
if (oldName === newName) return;
|
||||
const oldMd = nodeMdPath(p);
|
||||
const newFolder = normalizePath(`${parent}/${newName}`);
|
||||
const mdFile = app.vault.getAbstractFileByPath(oldMd);
|
||||
if (mdFile instanceof TFile) {
|
||||
const tmpMd = normalizePath(`${p}/${newName}.md`);
|
||||
if (tmpMd !== oldMd) await rename(app, oldMd, tmpMd);
|
||||
}
|
||||
await rename(app, p, newFolder);
|
||||
}
|
||||
|
||||
export async function moveNode(
|
||||
app: App,
|
||||
folderPath: string,
|
||||
newParentFolderPath: string,
|
||||
): Promise<void> {
|
||||
const p = normalizePath(folderPath);
|
||||
const name = p.split("/").pop() ?? "";
|
||||
const target = normalizePath(`${newParentFolderPath}/${name}`);
|
||||
if (p === target) return;
|
||||
await rename(app, p, target);
|
||||
}
|
||||
|
||||
export interface NodeTree {
|
||||
name: string;
|
||||
path: string;
|
||||
children: NodeTree[];
|
||||
}
|
||||
|
||||
export function buildNodeTree(app: App, projectName: string): NodeTree {
|
||||
const root = projectPath(projectName);
|
||||
const walk = (folderPath: string, name: string): NodeTree => {
|
||||
const children = listFolders(app, folderPath).map((f) => walk(f.path, f.name));
|
||||
return { name, path: folderPath, children };
|
||||
};
|
||||
return walk(root, projectName);
|
||||
}
|
||||
|
||||
export interface ProjectFileLocation {
|
||||
project: string;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export function parseProjectFilePath(path: string): ProjectFileLocation | null {
|
||||
if (!path.endsWith(".md")) return null;
|
||||
const parts = normalizePath(path).split("/");
|
||||
let idx = 0;
|
||||
if (PROJECTS_ROOT) {
|
||||
if (parts[0] !== PROJECTS_ROOT) return null;
|
||||
idx = 1;
|
||||
}
|
||||
if (parts.length < idx + 2) return null;
|
||||
const project = parts[idx];
|
||||
const rest = parts.slice(idx + 1);
|
||||
return { project, path: rest };
|
||||
}
|
||||
68
src/modals/NameModal.ts
Normal file
68
src/modals/NameModal.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { App, Modal, Setting } from "obsidian";
|
||||
|
||||
export interface NameModalOptions {
|
||||
title: string;
|
||||
label?: string;
|
||||
initial?: string;
|
||||
cta: string;
|
||||
validate?: (name: string) => string | null;
|
||||
onSubmit: (name: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export class NameModal extends Modal {
|
||||
private opts: NameModalOptions;
|
||||
private value: string;
|
||||
|
||||
constructor(app: App, opts: NameModalOptions) {
|
||||
super(app);
|
||||
this.opts = opts;
|
||||
this.value = opts.initial ?? "";
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText(this.opts.title);
|
||||
const setting = new Setting(this.contentEl)
|
||||
.setName(this.opts.label ?? "Name")
|
||||
.addText((t) =>
|
||||
t.setValue(this.value).onChange((v) => {
|
||||
this.value = v;
|
||||
}),
|
||||
);
|
||||
const input = setting.controlEl.querySelector("input");
|
||||
input?.focus();
|
||||
input?.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
new Setting(this.contentEl)
|
||||
.addButton((b) => b.setButtonText("Abbrechen").onClick(() => this.close()))
|
||||
.addButton((b) =>
|
||||
b
|
||||
.setButtonText(this.opts.cta)
|
||||
.setCta()
|
||||
.onClick(() => this.submit()),
|
||||
);
|
||||
}
|
||||
|
||||
private async submit(): Promise<void> {
|
||||
const name = this.value.trim();
|
||||
if (!name) return;
|
||||
if (this.opts.validate) {
|
||||
const err = this.opts.validate(name);
|
||||
if (err) {
|
||||
const existing = this.contentEl.querySelector(".pk-modal-error");
|
||||
if (existing) existing.remove();
|
||||
this.contentEl.createDiv({ cls: "pk-modal-error", text: err });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.opts.onSubmit(name);
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
125
src/ui.ts
Normal file
125
src/ui.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { App, Menu, Platform, TFile, View, WorkspaceLeaf, normalizePath } from "obsidian";
|
||||
|
||||
export interface CardOptions {
|
||||
title: string;
|
||||
body?: string | ((el: HTMLElement) => void);
|
||||
cls?: string;
|
||||
onClick?: (ev: MouseEvent) => void;
|
||||
onContextMenu?: (ev: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function card(parent: HTMLElement, opts: CardOptions): HTMLElement {
|
||||
const el = parent.createDiv({ cls: `pk-card ${opts.cls ?? ""}`.trim() });
|
||||
const header = el.createDiv({ cls: "pk-card-header", text: opts.title });
|
||||
if (opts.onClick) {
|
||||
header.addEventListener("click", opts.onClick);
|
||||
header.addClass("pk-clickable");
|
||||
}
|
||||
if (opts.onContextMenu) {
|
||||
el.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
opts.onContextMenu!(ev);
|
||||
});
|
||||
}
|
||||
if (opts.body) {
|
||||
const body = el.createDiv({ cls: "pk-card-body" });
|
||||
if (typeof opts.body === "string") {
|
||||
body.setText(opts.body);
|
||||
} else {
|
||||
opts.body(body);
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export function menu(ev: MouseEvent, items: Array<{ title: string; icon?: string; onClick: () => void }>): void {
|
||||
const m = new Menu();
|
||||
for (const it of items) {
|
||||
m.addItem((i) => {
|
||||
i.setTitle(it.title);
|
||||
if (it.icon) i.setIcon(it.icon);
|
||||
i.onClick(it.onClick);
|
||||
});
|
||||
}
|
||||
m.showAtMouseEvent(ev);
|
||||
}
|
||||
|
||||
export async function openMarkdown(
|
||||
app: App,
|
||||
path: string,
|
||||
leaf?: WorkspaceLeaf,
|
||||
): Promise<void> {
|
||||
const p = normalizePath(path);
|
||||
const f = app.vault.getAbstractFileByPath(p);
|
||||
if (!(f instanceof TFile)) return;
|
||||
if (Platform.isMobile) {
|
||||
const target = leaf ?? app.workspace.getLeaf(false);
|
||||
await target.openFile(f);
|
||||
return;
|
||||
}
|
||||
const existing = app.workspace.getLeavesOfType("markdown");
|
||||
if (existing.length > 0) {
|
||||
const target = existing[existing.length - 1];
|
||||
await target.openFile(f);
|
||||
app.workspace.revealLeaf(target);
|
||||
} else {
|
||||
const target = app.workspace.getLeaf("split", "vertical");
|
||||
await target.openFile(f);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BreadcrumbSegment {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function renderBreadcrumbInto(wrap: HTMLElement, segments: BreadcrumbSegment[]): void {
|
||||
segments.forEach((seg, i) => {
|
||||
if (i > 0) wrap.createSpan({ cls: "pk-breadcrumb-sep", text: " > " });
|
||||
if (seg.onClick) {
|
||||
const a = wrap.createSpan({ cls: "pk-breadcrumb-link", text: seg.label });
|
||||
a.addEventListener("click", seg.onClick);
|
||||
} else {
|
||||
wrap.createSpan({ cls: "pk-breadcrumb-current", text: seg.label });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): HTMLElement {
|
||||
const wrap = parent.createDiv({ cls: "pk-breadcrumb" });
|
||||
renderBreadcrumbInto(wrap, segments);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function injectMobileBreadcrumb(
|
||||
view: View & { contentEl?: HTMLElement },
|
||||
segments: BreadcrumbSegment[],
|
||||
): void {
|
||||
const root = view.contentEl ?? view.containerEl.querySelector(".view-content");
|
||||
if (!root) return;
|
||||
root.querySelectorAll(".pk-mobile-breadcrumb").forEach((el) => el.remove());
|
||||
if (segments.length === 0) return;
|
||||
const target =
|
||||
root.querySelector<HTMLElement>(".markdown-source-view .cm-sizer") ??
|
||||
root.querySelector<HTMLElement>(".markdown-reading-view .markdown-preview-sizer");
|
||||
if (!target) return;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "pk-breadcrumb pk-mobile-breadcrumb";
|
||||
target.prepend(wrap);
|
||||
renderBreadcrumbInto(wrap, segments);
|
||||
}
|
||||
|
||||
export function attachEmptyAreaMenu(
|
||||
el: HTMLElement,
|
||||
items: () => Array<{ title: string; icon?: string; onClick: () => void }>,
|
||||
): void {
|
||||
el.addEventListener("contextmenu", (ev) => {
|
||||
if (ev.defaultPrevented) return;
|
||||
ev.preventDefault();
|
||||
menu(ev, items());
|
||||
});
|
||||
}
|
||||
|
||||
export function emptyState(parent: HTMLElement, text: string): HTMLElement {
|
||||
return parent.createDiv({ cls: "pk-empty", text });
|
||||
}
|
||||
424
src/views/ProjectDetailsView.ts
Normal file
424
src/views/ProjectDetailsView.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import {
|
||||
ItemView,
|
||||
MarkdownRenderer,
|
||||
Notice,
|
||||
WorkspaceLeaf,
|
||||
ViewStateResult,
|
||||
normalizePath,
|
||||
} from "obsidian";
|
||||
import cytoscape, { Core, NodeSingular } from "cytoscape";
|
||||
// @ts-ignore — no types for cytoscape-fcose
|
||||
import fcose from "cytoscape-fcose";
|
||||
import {
|
||||
VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||
VIEW_TYPE_PROJECT_VIEW,
|
||||
RIBBON_ICON,
|
||||
CORE_FILE,
|
||||
DESCRIPTION_FILE,
|
||||
} from "../const";
|
||||
import {
|
||||
projectPath,
|
||||
readFile,
|
||||
deleteRecursive,
|
||||
createNode,
|
||||
renameNode,
|
||||
moveNode,
|
||||
buildNodeTree,
|
||||
nodeMdPath,
|
||||
NodeTree,
|
||||
} from "../fs";
|
||||
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
|
||||
import { NameModal } from "../modals/NameModal";
|
||||
|
||||
cytoscape.use(fcose);
|
||||
|
||||
export interface ProjectDetailsState extends Record<string, unknown> {
|
||||
project: string;
|
||||
}
|
||||
|
||||
const NAME_RX = /^[^\\/:*?"<>|]+$/;
|
||||
const ROOT_ID = ":root:";
|
||||
|
||||
function validateName(name: string, taken: string[], current?: string): string | null {
|
||||
if (!name) return "Name darf nicht leer sein.";
|
||||
if (!NAME_RX.test(name)) return "Ungültige Zeichen im Namen.";
|
||||
if (taken.includes(name) && name !== current) return "Name existiert bereits.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export class ProjectDetailsView extends ItemView {
|
||||
project = "";
|
||||
private renderToken = 0;
|
||||
private cy: Core | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
this.navigation = true;
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return VIEW_TYPE_PROJECT_DETAILS_VIEW;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return this.project ? `Projekt: ${this.project}` : "Projekt";
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return RIBBON_ICON;
|
||||
}
|
||||
|
||||
async setState(state: ProjectDetailsState, result: ViewStateResult): Promise<void> {
|
||||
this.project = state?.project ?? "";
|
||||
await super.setState(state, result);
|
||||
await this.render();
|
||||
}
|
||||
|
||||
getState(): ProjectDetailsState {
|
||||
return { project: this.project };
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
await this.render();
|
||||
this.registerEvent(this.app.vault.on("create", () => this.render()));
|
||||
this.registerEvent(this.app.vault.on("delete", () => this.render()));
|
||||
this.registerEvent(this.app.vault.on("rename", () => this.render()));
|
||||
this.registerEvent(this.app.vault.on("modify", (f) => {
|
||||
if (f.path.startsWith(projectPath(this.project) + "/")) this.render();
|
||||
}));
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
this.destroyCy();
|
||||
}
|
||||
|
||||
private destroyCy(): void {
|
||||
if (this.cy) {
|
||||
this.cy.destroy();
|
||||
this.cy = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async render(): Promise<void> {
|
||||
const token = ++this.renderToken;
|
||||
const root = this.containerEl.children[1] as HTMLElement;
|
||||
|
||||
if (!this.project) {
|
||||
this.destroyCy();
|
||||
root.empty();
|
||||
root.addClass("pk-root");
|
||||
emptyState(root, "Kein Projekt ausgewählt.");
|
||||
return;
|
||||
}
|
||||
|
||||
const projRoot = projectPath(this.project);
|
||||
const corePath = normalizePath(`${projRoot}/${CORE_FILE}`);
|
||||
const descriptionPath = normalizePath(`${projRoot}/${DESCRIPTION_FILE}`);
|
||||
const [core, description] = await Promise.all([
|
||||
readFile(this.app, corePath),
|
||||
readFile(this.app, descriptionPath),
|
||||
]);
|
||||
if (token !== this.renderToken) return;
|
||||
|
||||
this.destroyCy();
|
||||
root.empty();
|
||||
root.addClass("pk-root");
|
||||
|
||||
breadcrumb(root, [
|
||||
{ label: "Projekte", onClick: () => this.openProjectView() },
|
||||
{ label: this.project },
|
||||
]);
|
||||
|
||||
const info = root.createDiv({ cls: "pk-info-grid" });
|
||||
this.renderInfoCard(info, core, corePath);
|
||||
this.renderInfoCard(info, description, descriptionPath);
|
||||
this.renderGraph(root);
|
||||
}
|
||||
|
||||
private renderInfoCard(parent: HTMLElement, content: string, path: string): void {
|
||||
const btn = parent.createDiv({
|
||||
cls: "pk-btn-card pk-info-card",
|
||||
attr: { role: "button", tabindex: "0" },
|
||||
});
|
||||
btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf));
|
||||
if (content.trim()) {
|
||||
void MarkdownRenderer.render(this.app, content, btn, path, this);
|
||||
} else {
|
||||
btn.setText("(leer)");
|
||||
}
|
||||
}
|
||||
|
||||
private renderGraph(parent: HTMLElement): void {
|
||||
const container = parent.createDiv({ cls: "pk-graph" });
|
||||
const tree = buildNodeTree(this.app, this.project);
|
||||
const elements = this.treeToElements(tree);
|
||||
const styles = getComputedStyle(this.containerEl);
|
||||
const textColor = styles.getPropertyValue("--text-normal").trim() || "#222";
|
||||
const borderColor = styles.getPropertyValue("--background-modifier-border").trim() || "#ccc";
|
||||
const accent = styles.getPropertyValue("--interactive-accent").trim() || "#7b6cd9";
|
||||
const bgNode = styles.getPropertyValue("--background-primary").trim() || "#fff";
|
||||
|
||||
this.cy = cytoscape({
|
||||
container,
|
||||
elements,
|
||||
wheelSensitivity: 0.3,
|
||||
boxSelectionEnabled: false,
|
||||
userPanningEnabled: true,
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(label)",
|
||||
"text-valign": "center",
|
||||
"text-halign": "center",
|
||||
"background-color": bgNode,
|
||||
"border-color": borderColor,
|
||||
"border-width": 1,
|
||||
color: textColor,
|
||||
"font-size": 13,
|
||||
shape: "round-rectangle",
|
||||
width: "label" as unknown as number,
|
||||
height: 28,
|
||||
padding: "10px",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: `node[id = "${ROOT_ID}"]`,
|
||||
style: {
|
||||
"background-color": accent,
|
||||
color: "#fff",
|
||||
"border-width": 0,
|
||||
"font-weight": "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "node.pk-drop-target",
|
||||
style: {
|
||||
"border-color": accent,
|
||||
"border-width": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 1.5,
|
||||
"line-color": borderColor,
|
||||
"target-arrow-color": borderColor,
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
name: "fcose",
|
||||
// @ts-ignore — fcose options not in cytoscape types
|
||||
quality: "default",
|
||||
randomize: true,
|
||||
animate: false,
|
||||
fit: true,
|
||||
padding: 30,
|
||||
nodeRepulsion: 8000,
|
||||
idealEdgeLength: 100,
|
||||
gravity: 0.25,
|
||||
fixedNodeConstraint: [
|
||||
{ nodeId: ROOT_ID, position: { x: 0, y: 0 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
this.bindGraphHandlers();
|
||||
}
|
||||
|
||||
private treeToElements(tree: NodeTree): cytoscape.ElementDefinition[] {
|
||||
const els: cytoscape.ElementDefinition[] = [];
|
||||
els.push({ data: { id: ROOT_ID, label: tree.name } });
|
||||
const walk = (node: NodeTree, parentId: string) => {
|
||||
for (const child of node.children) {
|
||||
els.push({ data: { id: child.path, label: child.name } });
|
||||
els.push({ data: { id: `${parentId}>${child.path}`, source: parentId, target: child.path } });
|
||||
walk(child, child.path);
|
||||
}
|
||||
};
|
||||
walk(tree, ROOT_ID);
|
||||
return els;
|
||||
}
|
||||
|
||||
private bindGraphHandlers(): void {
|
||||
if (!this.cy) return;
|
||||
const cy = this.cy;
|
||||
|
||||
cy.on("tap", "node", (ev) => {
|
||||
const node = ev.target as NodeSingular;
|
||||
const id = node.id();
|
||||
if (id === ROOT_ID) {
|
||||
const corePath = normalizePath(`${projectPath(this.project)}/${CORE_FILE}`);
|
||||
void openMarkdown(this.app, corePath, this.leaf);
|
||||
return;
|
||||
}
|
||||
void openMarkdown(this.app, nodeMdPath(id), this.leaf);
|
||||
});
|
||||
|
||||
cy.on("cxttap", "node", (ev) => {
|
||||
const node = ev.target as NodeSingular;
|
||||
const id = node.id();
|
||||
const originalEvent = ev.originalEvent as MouseEvent;
|
||||
originalEvent.preventDefault();
|
||||
if (id === ROOT_ID) {
|
||||
const parent = projectPath(this.project);
|
||||
menu(originalEvent, [
|
||||
{ title: "Neuer Child-Node", icon: "plus", onClick: () => this.openCreateChild(parent) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const folder = id;
|
||||
const parentPath = folder.substring(0, folder.lastIndexOf("/"));
|
||||
const currentName = folder.split("/").pop() ?? "";
|
||||
menu(originalEvent, [
|
||||
{ title: "Neuer Child-Node", icon: "plus", onClick: () => this.openCreateChild(folder) },
|
||||
{ title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(folder, currentName, parentPath) },
|
||||
{ title: "Löschen", icon: "trash", onClick: () => this.openDelete(folder) },
|
||||
]);
|
||||
});
|
||||
|
||||
cy.on("cxttap", (ev) => {
|
||||
if (ev.target !== cy) return;
|
||||
const originalEvent = ev.originalEvent as MouseEvent;
|
||||
originalEvent.preventDefault();
|
||||
menu(originalEvent, [
|
||||
{ title: "Neuer Child-Node", icon: "plus", onClick: () => this.openCreateChild(projectPath(this.project)) },
|
||||
]);
|
||||
});
|
||||
|
||||
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 sourcePath = node.id();
|
||||
const targetPath = target.id() === ROOT_ID ? projectPath(this.project) : target.id();
|
||||
const currentParent = sourcePath.substring(0, sourcePath.lastIndexOf("/"));
|
||||
if (currentParent === targetPath) {
|
||||
if (start) node.position(start);
|
||||
return;
|
||||
}
|
||||
void this.handleReparent(sourcePath, targetPath);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
return hit;
|
||||
}
|
||||
|
||||
private async handleReparent(sourcePath: string, newParentFolderPath: string): Promise<void> {
|
||||
const name = sourcePath.split("/").pop() ?? "";
|
||||
const target = normalizePath(`${newParentFolderPath}/${name}`);
|
||||
if (this.app.vault.getAbstractFileByPath(target)) {
|
||||
new Notice(`„${target}" existiert bereits.`);
|
||||
await this.render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await moveNode(this.app, sourcePath, newParentFolderPath);
|
||||
} catch (e) {
|
||||
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
|
||||
await this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private openCreateChild(parentFolderPath: string): void {
|
||||
const existing = this.childNames(parentFolderPath);
|
||||
new NameModal(this.app, {
|
||||
title: "Neuer Node",
|
||||
label: "Name",
|
||||
cta: "Erstellen",
|
||||
validate: (n) => validateName(n, existing),
|
||||
onSubmit: async (name) => {
|
||||
try {
|
||||
await createNode(this.app, parentFolderPath, name);
|
||||
await this.render();
|
||||
} catch (e) {
|
||||
new Notice(`Fehler: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
}).open();
|
||||
}
|
||||
|
||||
private openRename(folderPath: string, current: string, parentFolderPath: string): void {
|
||||
const taken = this.childNames(parentFolderPath);
|
||||
new NameModal(this.app, {
|
||||
title: "Node umbenennen",
|
||||
label: "Name",
|
||||
initial: current,
|
||||
cta: "Speichern",
|
||||
validate: (n) => validateName(n, taken, current),
|
||||
onSubmit: async (name) => {
|
||||
if (name === current) return;
|
||||
try {
|
||||
await renameNode(this.app, folderPath, name);
|
||||
await this.render();
|
||||
} catch (e) {
|
||||
new Notice(`Fehler: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
}).open();
|
||||
}
|
||||
|
||||
private async openDelete(folderPath: string): Promise<void> {
|
||||
try {
|
||||
await deleteRecursive(this.app, folderPath);
|
||||
await this.render();
|
||||
} catch (e) {
|
||||
new Notice(`Fehler: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private childNames(folderPath: string): string[] {
|
||||
const folder = this.app.vault.getAbstractFileByPath(normalizePath(folderPath));
|
||||
if (!folder || !("children" in folder)) return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const children = (folder as any).children as Array<{ name: string }> | undefined;
|
||||
if (!children) return [];
|
||||
return children
|
||||
.filter((c) => !c.name.endsWith(".md"))
|
||||
.map((c) => c.name);
|
||||
}
|
||||
|
||||
private async openProjectView(): Promise<void> {
|
||||
await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true });
|
||||
}
|
||||
}
|
||||
181
src/views/ProjectView.ts
Normal file
181
src/views/ProjectView.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
ItemView,
|
||||
MarkdownRenderer,
|
||||
Notice,
|
||||
WorkspaceLeaf,
|
||||
normalizePath,
|
||||
} from "obsidian";
|
||||
import {
|
||||
VIEW_TYPE_PROJECT_VIEW,
|
||||
VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||
RIBBON_ICON,
|
||||
CORE_FILE,
|
||||
PROJECTS_ROOT,
|
||||
} from "../const";
|
||||
import {
|
||||
projectsPath,
|
||||
projectPath,
|
||||
listFolders,
|
||||
ensureFolder,
|
||||
createProject,
|
||||
rename,
|
||||
deleteRecursive,
|
||||
readFile,
|
||||
} from "../fs";
|
||||
import { menu, breadcrumb, emptyState } from "../ui";
|
||||
import { NameModal } from "../modals/NameModal";
|
||||
|
||||
const NAME_RX = /^[^\\/:*?"<>|]+$/;
|
||||
|
||||
function validateName(name: string, taken: string[], current?: string): string | null {
|
||||
if (!name) return "Name darf nicht leer sein.";
|
||||
if (!NAME_RX.test(name)) return "Ungültige Zeichen im Namen.";
|
||||
if (taken.includes(name) && name !== current) return "Name existiert bereits.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export class ProjectView extends ItemView {
|
||||
private renderToken = 0;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
this.navigation = true;
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return VIEW_TYPE_PROJECT_VIEW;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return "Projekte";
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return RIBBON_ICON;
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
await ensureFolder(this.app, projectsPath());
|
||||
await this.render();
|
||||
this.registerEvent(this.app.vault.on("create", () => this.render()));
|
||||
this.registerEvent(this.app.vault.on("delete", () => this.render()));
|
||||
this.registerEvent(this.app.vault.on("rename", () => this.render()));
|
||||
this.registerEvent(this.app.vault.on("modify", (f) => {
|
||||
if (!f.path.endsWith("/" + CORE_FILE)) return;
|
||||
if (PROJECTS_ROOT && !f.path.startsWith(projectsPath() + "/")) return;
|
||||
this.render();
|
||||
}));
|
||||
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
|
||||
if (ev.defaultPrevented) return;
|
||||
ev.preventDefault();
|
||||
menu(ev, [
|
||||
{ title: "Neues Projekt", icon: "plus", onClick: () => this.openCreate() },
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {}
|
||||
|
||||
private async render(): Promise<void> {
|
||||
const token = ++this.renderToken;
|
||||
const root = this.containerEl.children[1] as HTMLElement;
|
||||
|
||||
const projects = listFolders(this.app, projectsPath());
|
||||
if (projects.length === 0) {
|
||||
if (token !== this.renderToken) return;
|
||||
root.empty();
|
||||
root.addClass("pk-root");
|
||||
breadcrumb(root, [{ label: "Projekte" }]);
|
||||
emptyState(root, "Noch keine Projekte. Rechtsklick → Neues Projekt.");
|
||||
return;
|
||||
}
|
||||
|
||||
const taken = projects.map((p) => p.name);
|
||||
const corePaths = projects.map((p) => normalizePath(`${projectPath(p.name)}/${CORE_FILE}`));
|
||||
const cores = await Promise.all(corePaths.map((p) => readFile(this.app, p)));
|
||||
if (token !== this.renderToken) return;
|
||||
|
||||
root.empty();
|
||||
root.addClass("pk-root");
|
||||
breadcrumb(root, [{ label: "Projekte" }]);
|
||||
|
||||
const grid = root.createDiv({ cls: "pk-grid" });
|
||||
for (let i = 0; i < projects.length; i++) {
|
||||
const proj = projects[i];
|
||||
const core = cores[i].trim();
|
||||
const corePath = corePaths[i];
|
||||
const btn = grid.createDiv({
|
||||
cls: "pk-btn-card",
|
||||
attr: { role: "button", tabindex: "0" },
|
||||
});
|
||||
btn.createEl("strong", { text: proj.name });
|
||||
if (core) {
|
||||
const body = btn.createDiv({ cls: "pk-project-core" });
|
||||
void MarkdownRenderer.render(this.app, core, body, corePath, this);
|
||||
}
|
||||
btn.addEventListener("click", () => this.openProjectDetails(proj.name));
|
||||
btn.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
menu(ev, [
|
||||
{ title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) },
|
||||
{ title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) },
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private openCreate(): void {
|
||||
const taken = listFolders(this.app, projectsPath()).map((p) => p.name);
|
||||
new NameModal(this.app, {
|
||||
title: "Neues Projekt",
|
||||
label: "Projektname",
|
||||
cta: "Erstellen",
|
||||
validate: (n) => validateName(n, taken),
|
||||
onSubmit: async (name) => {
|
||||
try {
|
||||
await createProject(this.app, name);
|
||||
await this.render();
|
||||
new Notice(`Projekt „${name}" erstellt.`);
|
||||
} catch (e) {
|
||||
new Notice(`Fehler: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
}).open();
|
||||
}
|
||||
|
||||
private openRename(current: string, taken: string[]): void {
|
||||
new NameModal(this.app, {
|
||||
title: "Projekt umbenennen",
|
||||
label: "Projektname",
|
||||
initial: current,
|
||||
cta: "Speichern",
|
||||
validate: (n) => validateName(n, taken, current),
|
||||
onSubmit: async (name) => {
|
||||
if (name === current) return;
|
||||
try {
|
||||
await rename(this.app, projectPath(current), projectPath(name));
|
||||
await this.render();
|
||||
} catch (e) {
|
||||
new Notice(`Fehler: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
}).open();
|
||||
}
|
||||
|
||||
private async openDelete(name: string): Promise<void> {
|
||||
try {
|
||||
await deleteRecursive(this.app, projectPath(name));
|
||||
await this.render();
|
||||
} catch (e) {
|
||||
new Notice(`Fehler: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async openProjectDetails(name: string): Promise<void> {
|
||||
await this.leaf.setViewState({
|
||||
type: VIEW_TYPE_PROJECT_DETAILS_VIEW,
|
||||
active: true,
|
||||
state: { project: name },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user