'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['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( (config.title as string) || t('note.defaultTitle'), ); const [content, setContent] = useState( (config.content as string) || '', ); const [isEditing, setIsEditing] = useState(false); const [saveError, setSaveError] = useState(false); const timerRef = useRef | undefined>(undefined); const abortRef = useRef(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) => { 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(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('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( () => ({ rehypePlugins: [[rehypeSanitize]], components: { input: (props: { checked?: boolean }) => ( handleCheckboxToggleRef.current(element)} /> ), }, }), [], ); return (
{/* Header: title + pencil toggle */}
{saveError && ( )}
{/* Content: preview or editor */}
); }