Files
tessera-ctl/apps/web/src/components/dashboard/widgets/note-widget.tsx
T
schalli b601141bcf fix(quick-260921-jt4): Notiz-Aufgabenkaestchen bedient sich selbst
- NoteCheckbox traegt jetzt ein echtes onChange und gibt sein eigenes
  DOM-Element an onToggle weiter; readOnly entfaellt (D-01) — war bisher
  nur da, um Reacts Warnung ueber ein gesteuertes Feld ohne onChange zu
  unterdruecken
- Index-Ermittlung bleibt wortgleich (alle Kaestchen im Behaelter
  einsammeln, indexOf auf dem ausloesenden Element), wandert aber vom
  Behaelter-onClick in handleCheckboxToggle, das den Behaelter ueber ein
  ref statt event.currentTarget findet
- previewOptions als useMemo mit leerer Abhaengigkeitsliste, Rueckruf
  ueber ein ref erreicht — identitaetsstabil wie die alte Modulkonstante,
  T-JT4-03: rehypePlugins: [[rehypeSanitize]] unveraendert erhalten
- Tests: echte Tastaturbetaetigung (Leertaste auf fokussiertem
  Kaestchen) UND echter Klick loesen onToggle/PATCH aus; neue Tests
  belegen readOnly/disabled entfallen

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:47:45 +02:00

225 lines
7.7 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import MDEditor, { commands } from '@uiw/react-md-editor';
import rehypeSanitize from 'rehype-sanitize';
import type { WidgetProps } from '../widget-registry';
import { updateWidgetConfig } from '@/lib/dashboard-api';
import { NoteCheckbox, toggleTaskLine } from './note-task-list';
const DEBOUNCE_MS = 1500;
// quick-260916-iex: Aufgabenlisten sind in der Ansicht direkt abhakbar —
// `components.input` ersetzt das per rehypeSanitize deaktivierte `input`
// durch NoteCheckbox (siehe note-task-list.tsx). Typ direkt von MDEditor
// abgeleitet statt aus dem transitiven Paket `@uiw/react-markdown-preview`
// importiert (das ist keine direkte Dependency von apps/web und daher unter
// pnpms strikter Isolation nicht von hier aus aufloesbar).
type PreviewOptions = NonNullable<
React.ComponentProps<typeof MDEditor>['previewOptions']
>;
const NOTE_COMMANDS = [
commands.bold,
commands.italic,
commands.strikethrough,
commands.divider,
commands.unorderedListCommand,
commands.checkedListCommand,
commands.divider,
commands.link,
commands.code,
];
export function NoteWidget({ instanceId, config }: WidgetProps) {
const t = useTranslations('widgets');
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// quick-260916-j4f: folgt dem Tessera-Schalter statt der
// Betriebssystem-Einstellung, Muster changelog-view.tsx.
const colorMode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
const [title, setTitle] = useState<string>(
(config.title as string) || t('note.defaultTitle'),
);
const [content, setContent] = useState<string>(
(config.content as string) || '',
);
const [isEditing, setIsEditing] = useState(false);
const [saveError, setSaveError] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const abortRef = useRef<AbortController | undefined>(undefined);
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
abortRef.current?.abort();
};
}, []);
const save = useCallback(
async (newContent: string, newTitle: string) => {
abortRef.current?.abort();
abortRef.current = new AbortController();
try {
await updateWidgetConfig(instanceId, { content: newContent, title: newTitle }, abortRef.current.signal);
setSaveError(false);
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') return;
setSaveError(true);
}
},
[instanceId],
);
const scheduleSave = useCallback(
(newContent: string, newTitle: string) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => save(newContent, newTitle), DEBOUNCE_MS);
},
[save],
);
const handleContentChange = useCallback(
(val?: string) => {
const newContent = val ?? '';
setContent(newContent);
scheduleSave(newContent, title);
},
[scheduleSave, title],
);
const handleTitleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newTitle = e.target.value;
setTitle(newTitle);
scheduleSave(content, newTitle);
},
[scheduleSave, content],
);
// Vorschau-Behaelter: die Index-Ermittlung (n-tes Kaestchen im Behaelter ==
// n-te Aufgabenzeile) bleibt Wort fuer Wort wie zuvor, findet den Behaelter
// jetzt aber ueber ein ref statt ueber event.currentTarget, weil der Klick
// ab jetzt am Kaestchen selbst entsteht (quick-260921-jt4, D-01: das
// Kaestchen bedient sich selbst statt vom Behaelter bedient zu werden).
const previewRef = useRef<HTMLDivElement>(null);
// quick-260916-iex: Abhaken eines Aufgaben-Kaestchens kippt genau diese
// Zeile und speichert sofort (kein Entprellen — eine Tastatur-/Maus-
// betaetigung ist eine abgeschlossene Handlung). Ein noch laufender
// Entprell-Timer aus dem Tippen wird verworfen, damit er den ungekippten
// Text nicht nachschiebt.
const handleCheckboxToggle = useCallback(
(element: HTMLInputElement) => {
if (isEditing) return;
const container = previewRef.current;
if (!container) return;
const boxes = Array.from(
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
);
const index = boxes.indexOf(element);
const next = toggleTaskLine(content, index);
if (next === content) return;
setContent(next);
clearTimeout(timerRef.current);
void save(next, title);
},
[isEditing, content, title, save],
);
// Der Rueckruf, den `previewOptions.components.input` letztlich aufruft,
// haengt via ref an der jeweils aktuellen `handleCheckboxToggle` — das
// haelt `previewOptions` selbst identitaetsstabil (useMemo mit leerer
// Abhaengigkeitsliste), waehrend der Rueckruf trotzdem immer den frischen
// Zustand sieht. Ein pro Tastendruck neu erzeugtes Optionsobjekt liesse
// react-markdown bei jedem Zeichen neu abgleichen.
const handleCheckboxToggleRef = useRef(handleCheckboxToggle);
handleCheckboxToggleRef.current = handleCheckboxToggle;
const previewOptions = useMemo<PreviewOptions>(
() => ({
rehypePlugins: [[rehypeSanitize]],
components: {
input: (props: { checked?: boolean }) => (
<NoteCheckbox
checked={props.checked}
onToggle={(element) => handleCheckboxToggleRef.current(element)}
/>
),
},
}),
[],
);
return (
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
{/* Header: title + pencil toggle */}
<div className="relative flex items-center border-b border-border px-1.5 py-1.5 gap-2">
<input
type="text"
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
value={title}
onChange={handleTitleChange}
placeholder={t('note.defaultTitle')}
/>
{saveError && (
<span
className="h-2 w-2 shrink-0 rounded-full bg-destructive"
title={t('note.autosaveError')}
/>
)}
<button
type="button"
onClick={() => setIsEditing((v) => !v)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-muted ${isEditing ? 'text-destructive' : 'text-muted-foreground'}`}
title={isEditing ? t('note.viewMode') : t('note.editMode')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</div>
{/* Content: preview or editor */}
<div
className="flex-1 overflow-auto"
data-testid="note-preview"
ref={previewRef}
>
<MDEditor
data-testid="md-editor"
value={content}
onChange={isEditing ? handleContentChange : undefined}
commands={NOTE_COMMANDS}
preview={isEditing ? 'edit' : 'preview'}
hideToolbar={!isEditing}
height="100%"
visibleDragbar={false}
previewOptions={previewOptions}
/>
</div>
</div>
);
}