Files
ai-control/src/panel-view.ts
T
marcus.hinz 165501c8f9 Panel: Clobber-Schutz, Content-Editor, Archiv-Reveal/Guard, Rechtschreib-Sprache
- register_mcp_server schreibt .claude.json nur bei fehlendem/abweichendem
  Eintrag (claudes Live-State nicht unnötig ueberschreiben).
- Content-Editor im Panel (Rohtext-Textarea): Cmd/Ctrl+Enter speichert via
  panel_set, Esc verwirft; eingehende Updates werden waehrend der Bearbeitung
  gepuffert (Button "geaendert"), Speichern gewinnt.
- Archivieren zeigt die Datei danach im Dateimanager (reveal_path_cmd, cfg pro
  OS). Breadth-Guard: Archiv-Ordner darf kein Home-Vorfahre sein, muss absolut
  sein; expand_home loest bare ~ auf.
- Rechtschreibpruefung: Default in settings.json (spellcheckLang "de"), pro
  Text per Selector ueberschreibbar.
2026-07-11 14:24:16 +02:00

221 lines
6.8 KiB
TypeScript

import { marked } from "marked";
export interface PanelView {
set(text: string): void;
raw(): string;
}
/// Verkabelt einen Panel-Inhaltsbereich: MD/Roh-Umschalter und Copy-Button.
/// Der Rohtext bleibt die Quelle für „Kopieren"; die Ansicht rendert Markdown.
/// Entfernt Inline-Markdown (Emphasis, Code, Links), damit der Plain-Text-Titel
/// nicht die rohen Marker zeigt.
function stripInlineMd(s: string): string {
return s
.replace(/`([^`]*)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/_([^_]+)_/g, "$1")
.replace(/~~([^~]+)~~/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.trim();
}
/// Titel für den Panel-Kopf: erste Überschrift (# …) oder sonst erste
/// nicht-leere Zeile, ohne Inline-Markdown; „Entwurf" bei leerem Text.
function firstLine(text: string): string {
let fallback = "";
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
const h = t.replace(/^#+/, "").trim();
if (t.startsWith("#") && h) return stripInlineMd(h);
if (!fallback) fallback = t;
}
return fallback ? stripInlineMd(fallback) : "Entwurf";
}
/// Ersetzt die erste Überschrift (bzw. legt eine an) im Rohtext durch `title`.
function setHeading(text: string, title: string): string {
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t) continue;
if (t.startsWith("#")) {
const hashes = (t.match(/^#+/) as RegExpMatchArray)[0];
lines[i] = `${hashes} ${title}`;
} else {
lines.splice(i, 0, `# ${title}`, "");
}
return lines.join("\n");
}
return `# ${title}\n`;
}
export function initPanelView(opts: {
content: HTMLElement;
copyBtn: HTMLElement;
modeBtn: HTMLElement;
titleEl?: HTMLElement;
editBtn?: HTMLElement;
editContentBtn?: HTMLElement;
langSelect?: HTMLSelectElement;
/// Standard-Sprache der Rechtschreibprüfung (aus den App-Settings).
defaultLang?: string;
/// Wird mit dem neuen Rohtext aufgerufen, wenn Titel oder Inhalt geändert
/// wurden.
onCommit?: (text: string) => void;
}): PanelView {
let rawText = "";
let rendered = true;
// Content-Edit-Zustand: während des Editierens werden eingehende Updates
// gepuffert statt angewandt.
let editing = false;
let pending: string | null = null;
function draw() {
if (rendered) {
opts.content.className = "md";
opts.content.innerHTML = marked.parse(rawText, { async: false });
} else {
opts.content.className = "raw";
opts.content.textContent = rawText;
}
opts.modeBtn.textContent = rendered ? "MD" : "Roh";
}
opts.modeBtn.addEventListener("click", () => {
rendered = !rendered;
draw();
});
opts.copyBtn.addEventListener("click", async () => {
await navigator.clipboard.writeText(rawText);
opts.copyBtn.classList.add("copied");
setTimeout(() => opts.copyBtn.classList.remove("copied"), 1200);
});
// Titel-Edit: Edit-Button macht den Titel editierbar, Enter/Blur schreibt die
// geänderte Überschrift zurück, Escape verwirft.
const titleEl = opts.titleEl;
if (opts.editBtn && titleEl && opts.onCommit) {
const commit = () => {
if (titleEl.getAttribute("contenteditable") !== "true") return;
titleEl.removeAttribute("contenteditable");
const nt = (titleEl.textContent || "").trim();
if (nt && nt !== firstLine(rawText)) opts.onCommit!(setHeading(rawText, nt));
else titleEl.textContent = firstLine(rawText);
};
opts.editBtn.addEventListener("click", () => {
titleEl.setAttribute("contenteditable", "true");
titleEl.focus();
const r = document.createRange();
r.selectNodeContents(titleEl);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(r);
});
titleEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
titleEl.blur();
} else if (e.key === "Escape") {
titleEl.textContent = firstLine(rawText);
titleEl.removeAttribute("contenteditable");
titleEl.blur();
}
});
titleEl.addEventListener("blur", commit);
}
// Content-Edit: Button schaltet den Inhalt auf eine Rohtext-Textarea. Speichern
// (Button erneut oder Cmd/Ctrl+Enter) schreibt zurück, Escape verwirft.
// Eingehende Updates während des Editierens werden gepuffert.
if (opts.editContentBtn && opts.onCommit) {
const editBtn = opts.editContentBtn;
const editor = document.createElement("textarea");
editor.className = "panel-editor";
editor.spellcheck = true;
editor.hidden = true;
opts.content.after(editor);
// Sprache der Rechtschreibprüfung: Default aus den Settings, per Selector
// pro Text überschreibbar.
let lang = opts.defaultLang || "de";
const sel = opts.langSelect;
if (sel) {
if ([...sel.options].some((o) => o.value === lang)) sel.value = lang;
else lang = sel.value;
sel.addEventListener("change", () => {
lang = sel.value;
editor.lang = lang;
// Neuprüfung erzwingen.
editor.spellcheck = false;
editor.spellcheck = true;
if (!editor.hidden) editor.focus();
});
}
editor.lang = lang;
const leave = () => {
editing = false;
editor.hidden = true;
opts.content.hidden = false;
editBtn.classList.remove("active", "changed");
};
const enter = () => {
editing = true;
pending = null;
editor.value = rawText;
opts.content.hidden = true;
editor.hidden = false;
editBtn.classList.add("active");
editor.focus();
};
const save = () => {
const v = editor.value;
leave();
opts.onCommit!(v); // schreibt Datei -> panel-update -> set() rendert
};
const cancel = () => {
const p = pending;
leave();
if (p !== null) applyText(p);
else draw();
};
editBtn.addEventListener("click", () => (editing ? save() : enter()));
editor.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
});
}
function applyText(text: string) {
rawText = text;
if (titleEl && titleEl.getAttribute("contenteditable") !== "true") {
titleEl.textContent = firstLine(text);
}
draw();
}
draw();
return {
set(text: string) {
// Während einer Inhalts-Bearbeitung nicht überschreiben — puffern und den
// Edit-Button als „geändert" markieren.
if (editing) {
pending = text;
opts.editContentBtn?.classList.add("changed");
return;
}
applyText(text);
},
raw: () => rawText,
};
}