Files
tessera-ctl/apps/web/src/components/dashboard/widgets/note-task-list.tsx
T
schalli 684f063a8e feat(web): Notiz-Widget — Aufgabenlisten in der Ansicht abhakbar
- Hilfsmodul note-task-list.tsx mit isTaskLine/toggleTaskLine (GFM-konforme
  Regex, Code-Zaeune werden uebersprungen) und NoteCheckbox (kein disabled)
- previewOptions des Notiz-Widgets reicht components.input = NoteCheckbox
  durch react-markdown nach rehypeSanitize durch (bleibt aktiv, T-IEX-01)
- Klick im Vorschau-Container kippt genau die N-te Aufgabenzeile und
  speichert sofort (verwirft einen laufenden Tipp-Entprell-Timer)
- Hinweis: note-task-list als .tsx statt .ts angelegt (Komponente braucht
  JSX) — Abweichung dokumentiert in SUMMARY.md
- 16 neue/erweiterte Tests gruen, tsc Exit 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:36:17 +02:00

101 lines
3.6 KiB
TypeScript

/**
* note-task-list — reine Hilfsfunktionen + Kaestchen-Komponente fuer das
* Notiz-Widget (quick-260916-iex).
*
* `rehypeSanitize` (hast-util-sanitize) erzwingt bei `input`-Elementen
* grundsaetzlich `disabled=true`. Die Loesung ersetzt NICHT das
* Sanitize-Verhalten, sondern das gerenderte Element selbst: react-markdown
* (ueber `@uiw/react-md-editor`s `previewOptions.components`) laesst uns das
* `input`-Tag durch eine eigene Komponente ersetzen, die NACH dem Sanitize
* ausgefuehrt wird und deshalb selbst entscheidet, ob sie `disabled` setzt.
* `rehypeSanitize` bleibt aktiv (XSS-Schranke fuer den Rest des Markdowns,
* T-IEX-01).
*
* Die Zuordnung Klick -> Zeile laeuft ueber einen Index (n-tes Kaestchen im
* DOM == n-te Aufgabenzeile im Markdown). Damit das stimmt, MUSS
* `isTaskLine` exakt dieselben Zeilen als Aufgabe erkennen wie die
* GFM-Erkennung von react-markdown (remark-gfm /
* micromark-extension-gfm-task-list-item): nach der schliessenden Klammer
* muss Leerraum UND mindestens ein Nicht-Leerraum-Zeichen folgen. Eine
* laxere Regex wuerde bei einer noch leeren Aufgabenzeile (`- [ ]`, typisch
* beim Tippen einer neuen Aufgabe) den Index verschieben und beim Klick die
* falsche Zeile kippen.
*
* Vorbild: `toggleMarkdownCheckbox` im alten persoenlichen Dashboard des
* Anwenders (user-files/personal-dashboard/src/app/page.tsx, ~Z. 1399).
*/
/**
* Erkennt eine Markdown-Aufgabenzeile (`- [ ] Text`, `* [x] Text`,
* `+ [X] Text`, `1. [ ] Text`, `1) [ ] Text`, auch eingerueckt). Bewusst
* streng: nach `]` muss Leerraum und danach mindestens ein
* Nicht-Leerraum-Zeichen folgen (siehe Modulkommentar).
*/
export const TASK_LINE_RE =
/^(\s*(?:[-*+]|\d+[.)])\s+\[)([ \txX])(\]\s+\S.*)$/;
const FENCE_RE = /^\s*(```|~~~)/;
export function isTaskLine(line: string): boolean {
return TASK_LINE_RE.test(line);
}
/**
* Kippt die `index`-te Aufgabenzeile (0-basiert, Zeilen in Code-Zaeunen
* zaehlen nicht mit) zwischen `[ ]` und `[x]`. Gibt bei ungueltigem Index
* (< 0 oder >= Anzahl Aufgabenzeilen) dieselbe Referenz wie `content`
* zurueck, damit Aufrufer per `===` erkennen, dass nichts zu speichern ist.
*/
export function toggleTaskLine(content: string, index: number): string {
if (index < 0) return content;
const lines = content.split('\n');
let inFence = false;
let taskCounter = 0;
let changed = false;
const nextLines = lines.map((line) => {
if (FENCE_RE.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
if (!isTaskLine(line)) return line;
const isTarget = taskCounter === index;
taskCounter += 1;
if (!isTarget) return line;
const match = line.match(TASK_LINE_RE);
if (!match) return line;
const [, prefix, mark, suffix] = match;
const newMark = mark === ' ' || mark === '\t' ? 'x' : ' ';
changed = true;
return `${prefix}${newMark}${suffix}`;
});
if (!changed) return content;
return nextLines.join('\n');
}
/**
* Ersetzt das von react-markdown gerenderte `<input>` einer Aufgabenzeile.
* Zieht bewusst NUR `checked` aus den Props (react-markdown reicht
* zusaetzlich `node`, `disabled`, `type` durch) — sonst landen fremde
* Attribute (insbesondere `node`) im DOM. Kein `disabled`: das Kaestchen
* bleibt anklickbar. `readOnly` unterdrueckt Reacts Warnung
* "checked ohne onChange" — der Klick wird nicht am Kaestchen selbst,
* sondern delegiert am Vorschau-Container verarbeitet.
*/
export function NoteCheckbox({ checked }: { checked?: boolean }) {
return (
<input
type="checkbox"
checked={!!checked}
readOnly
className="cursor-pointer"
/>
);
}