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
This commit is contained in:
2026-09-21 14:47:45 +02:00
parent 0c89c13bb2
commit b601141bcf
4 changed files with 164 additions and 34 deletions
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
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';
@@ -13,21 +13,14 @@ 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). Stabile Konstante ausserhalb
// der Komponente, damit react-markdown nicht bei jedem Render neu diffed.
// 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).
// 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 PREVIEW_OPTIONS: PreviewOptions = {
rehypePlugins: [[rehypeSanitize]],
components: { input: NoteCheckbox },
};
const NOTE_COMMANDS = [
commands.bold,
commands.italic,
@@ -113,23 +106,27 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
[scheduleSave, content],
);
// quick-260916-iex: Klick auf ein Aufgaben-Kaestchen in der Ansicht kippt
// genau diese Zeile und speichert sofort (kein Entprellen — ein Klick ist
// eine abgeschlossene Handlung). Ein noch laufender Entprell-Timer aus dem
// Tippen wird verworfen, damit er den ungekippten Text nicht nachschiebt.
const handlePreviewClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
// 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 target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') {
return;
}
const container = previewRef.current;
if (!container) return;
const boxes = Array.from(
event.currentTarget.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"]',
),
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
);
const index = boxes.indexOf(target);
const index = boxes.indexOf(element);
const next = toggleTaskLine(content, index);
if (next === content) return;
setContent(next);
@@ -139,6 +136,30 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
[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 */}
@@ -184,7 +205,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
<div
className="flex-1 overflow-auto"
data-testid="note-preview"
onClick={handlePreviewClick}
ref={previewRef}
>
<MDEditor
data-testid="md-editor"
@@ -195,7 +216,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
hideToolbar={!isEditing}
height="100%"
visibleDragbar={false}
previewOptions={PREVIEW_OPTIONS}
previewOptions={previewOptions}
/>
</div>
</div>