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>
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import MDEditor from '@uiw/react-md-editor';
|
||||||
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
import { NoteCheckbox, isTaskLine, toggleTaskLine } from './note-task-list';
|
||||||
|
|
||||||
|
describe('note-task-list', () => {
|
||||||
|
describe('isTaskLine', () => {
|
||||||
|
it('erkennt gueltige Aufgabenzeilen', () => {
|
||||||
|
expect(isTaskLine('- [ ] Milch')).toBe(true);
|
||||||
|
expect(isTaskLine('- [x] Brot')).toBe(true);
|
||||||
|
expect(isTaskLine('* [X] Eier')).toBe(true);
|
||||||
|
expect(isTaskLine('+ [ ] Butter')).toBe(true);
|
||||||
|
expect(isTaskLine('1. [ ] Mehl')).toBe(true);
|
||||||
|
expect(isTaskLine('2) [x] Salz')).toBe(true);
|
||||||
|
expect(isTaskLine(' - [ ] eingerueckt')).toBe(true);
|
||||||
|
expect(isTaskLine('- [\t] Tab')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt Nicht-Aufgabenzeilen ab', () => {
|
||||||
|
expect(isTaskLine('- [ ]')).toBe(false);
|
||||||
|
expect(isTaskLine('- [ ]Text')).toBe(false);
|
||||||
|
expect(isTaskLine('- Milch')).toBe(false);
|
||||||
|
expect(isTaskLine('[ ] ohne Punkt')).toBe(false);
|
||||||
|
expect(isTaskLine('- [y] falsch')).toBe(false);
|
||||||
|
expect(isTaskLine('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleTaskLine', () => {
|
||||||
|
it('kippt genau die Zeile am angegebenen Index auf [x]', () => {
|
||||||
|
const content = '- [ ] Milch\n- [ ] Brot\n- [ ] Eier';
|
||||||
|
expect(toggleTaskLine(content, 0)).toBe(
|
||||||
|
'- [x] Milch\n- [ ] Brot\n- [ ] Eier',
|
||||||
|
);
|
||||||
|
expect(toggleTaskLine(content, 2)).toBe(
|
||||||
|
'- [ ] Milch\n- [ ] Brot\n- [x] Eier',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kippt [x]/[X] zurueck auf [ ]', () => {
|
||||||
|
expect(toggleTaskLine('- [x] Brot', 0)).toBe('- [ ] Brot');
|
||||||
|
expect(toggleTaskLine('- [X] Brot', 0)).toBe('- [ ] Brot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zaehlt Nicht-Aufgabenzeilen nicht mit, alle anderen Zeilen bleiben unveraendert', () => {
|
||||||
|
const content = '# Einkauf\n\nText\n- [ ] Milch\n- normal\n- [ ] Brot';
|
||||||
|
const result = toggleTaskLine(content, 1);
|
||||||
|
expect(result).toBe('# Einkauf\n\nText\n- [ ] Milch\n- normal\n- [x] Brot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behaelt Einrueckung und Nummerierung bei', () => {
|
||||||
|
const content = '- [ ] A\n - [ ] B\n1. [ ] C';
|
||||||
|
expect(toggleTaskLine(content, 1)).toBe('- [ ] A\n - [x] B\n1. [ ] C');
|
||||||
|
expect(toggleTaskLine(content, 2)).toBe('- [ ] A\n - [ ] B\n1. [x] C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt bei Index ausserhalb des Bereichs die unveraenderte Eingabe zurueck', () => {
|
||||||
|
const content = '- [ ] A\n- [ ] B\n- [ ] C';
|
||||||
|
expect(toggleTaskLine(content, -1)).toBe(content);
|
||||||
|
expect(toggleTaskLine(content, 3)).toBe(content);
|
||||||
|
expect(toggleTaskLine('', 0)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ueberspringt Zeilen innerhalb von Code-Zaeunen', () => {
|
||||||
|
const withBackticks = '```\n- [ ] nicht\n```\n- [ ] echt';
|
||||||
|
expect(toggleTaskLine(withBackticks, 0)).toBe(
|
||||||
|
'```\n- [ ] nicht\n```\n- [x] echt',
|
||||||
|
);
|
||||||
|
|
||||||
|
const withTilde = '~~~\n- [ ] nicht\n~~~\n- [ ] echt';
|
||||||
|
expect(toggleTaskLine(withTilde, 0)).toBe(
|
||||||
|
'~~~\n- [ ] nicht\n~~~\n- [x] echt',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('echte Vorschau (MDEditor.Markdown + rehypeSanitize + components-Override)', () => {
|
||||||
|
it('rendert genau die Aufgabenzeilen als anklickbare Kaestchen', () => {
|
||||||
|
const SRC =
|
||||||
|
'- [ ] eins\n- [x] zwei\n* [X] drei\n- [ ]\n- [ ]kein\n1. [ ] vier';
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MDEditor.Markdown
|
||||||
|
source={SRC}
|
||||||
|
rehypePlugins={[[rehypeSanitize]]}
|
||||||
|
components={{ input: NoteCheckbox }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const boxes = container.querySelectorAll('input[type="checkbox"]');
|
||||||
|
const expectedCount = SRC.split('\n').filter(isTaskLine).length;
|
||||||
|
|
||||||
|
expect(expectedCount).toBe(4);
|
||||||
|
expect(boxes.length).toBe(4);
|
||||||
|
|
||||||
|
for (const box of Array.from(boxes)) {
|
||||||
|
expect((box as HTMLInputElement).disabled).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((boxes[0] as HTMLInputElement).checked).toBe(false);
|
||||||
|
expect((boxes[1] as HTMLInputElement).checked).toBe(true);
|
||||||
|
expect((boxes[2] as HTMLInputElement).checked).toBe(true);
|
||||||
|
expect((boxes[3] as HTMLInputElement).checked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,23 +12,53 @@ vi.mock('next-intl', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute
|
// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute.
|
||||||
|
// Im Modus 'preview' wird stattdessen ein Vorschau-Div gerendert, das fuer
|
||||||
|
// jede Aufgabenzeile ein Kaestchen enthaelt (quick-260916-iex) — nutzt die
|
||||||
|
// echte `components.input`-Komponente aus `previewOptions`, wenn vorhanden,
|
||||||
|
// damit auch NoteCheckbox im Klickpfad landet.
|
||||||
|
const TASK_MOCK_RE = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+\S/;
|
||||||
|
|
||||||
vi.mock('@uiw/react-md-editor', () => {
|
vi.mock('@uiw/react-md-editor', () => {
|
||||||
const MDEditor = ({
|
const MDEditor = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
preview,
|
||||||
|
previewOptions,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (val?: string) => void;
|
onChange?: (val?: string) => void;
|
||||||
|
preview?: string;
|
||||||
|
previewOptions?: { components?: { input?: React.ComponentType<{ checked?: boolean }> } };
|
||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
}) => (
|
}) => {
|
||||||
<textarea
|
if (preview === 'preview') {
|
||||||
data-testid={testId ?? 'md-editor'}
|
const InputComponent = previewOptions?.components?.input;
|
||||||
value={value}
|
const lines = (value ?? '').split('\n');
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
return (
|
||||||
/>
|
<div data-testid={testId ?? 'md-editor'}>
|
||||||
);
|
{lines.map((line, i) => {
|
||||||
|
const match = line.match(TASK_MOCK_RE);
|
||||||
|
if (!match) return null;
|
||||||
|
const checked = match[1] !== ' ';
|
||||||
|
return InputComponent ? (
|
||||||
|
<InputComponent key={i} checked={checked} />
|
||||||
|
) : (
|
||||||
|
<input key={i} type="checkbox" readOnly checked={checked} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-testid={testId ?? 'md-editor'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
MDEditor.displayName = 'MDEditor';
|
MDEditor.displayName = 'MDEditor';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -171,4 +201,93 @@ describe('NoteWidget', () => {
|
|||||||
|
|
||||||
abortSpy.mockRestore();
|
abortSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// quick-260916-iex: Aufgabenlisten in der Ansicht abhakbar
|
||||||
|
it('kippt beim Klick auf ein Kaestchen in der Ansicht genau diese Zeile und speichert sofort', async () => {
|
||||||
|
render(
|
||||||
|
<NoteWidget
|
||||||
|
instanceId="note-1"
|
||||||
|
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const boxes = screen.getAllByRole('checkbox');
|
||||||
|
expect(boxes).toHaveLength(3);
|
||||||
|
|
||||||
|
fireEvent.click(boxes[2]);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toContain('/dashboard/widgets/note-1/config');
|
||||||
|
expect(init.method).toBe('PATCH');
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
config: { content: '- [ ] Milch\n- [x] Brot\n- [x] Eier', title: 'Einkauf' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((screen.getAllByRole('checkbox')[2] as HTMLInputElement).checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kippt ein angehaktes Kaestchen beim Klick wieder ab', async () => {
|
||||||
|
render(
|
||||||
|
<NoteWidget
|
||||||
|
instanceId="note-1"
|
||||||
|
config={{ content: '- [ ] Milch\n- [x] Brot\n- [ ] Eier', title: 'Einkauf' }}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const boxes = screen.getAllByRole('checkbox');
|
||||||
|
fireEvent.click(boxes[1]);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
config: { content: '- [ ] Milch\n- [ ] Brot\n- [ ] Eier', title: 'Einkauf' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwirft einen noch laufenden Entprell-Timer aus dem Tippen beim Abhaken', async () => {
|
||||||
|
render(
|
||||||
|
<NoteWidget
|
||||||
|
instanceId="note-1"
|
||||||
|
config={{ content: '- [ ] Milch\n- [ ] Brot', title: 'Einkauf' }}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stift an: in den Bearbeitungsmodus wechseln und tippen
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
});
|
||||||
|
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(textarea, { target: { value: '- [ ] Milch\n- [ ] Brot' } });
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Stift wieder aus: zurueck in die Ansicht
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Erstes Kaestchen anklicken -> sofortiges Speichern, alter Timer verworfen
|
||||||
|
const boxes = screen.getAllByRole('checkbox');
|
||||||
|
fireEvent.click(boxes[0]);
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
config: { content: '- [x] Milch\n- [ ] Brot', title: 'Einkauf' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der alte, eigentlich noch laufende Timer darf nichts mehr nachschieben
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,27 @@ import MDEditor, { commands } from '@uiw/react-md-editor';
|
|||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
import type { WidgetProps } from '../widget-registry';
|
import type { WidgetProps } from '../widget-registry';
|
||||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
|
import { NoteCheckbox, toggleTaskLine } from './note-task-list';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 1500;
|
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).
|
||||||
|
type PreviewOptions = NonNullable<
|
||||||
|
React.ComponentProps<typeof MDEditor>['previewOptions']
|
||||||
|
>;
|
||||||
|
|
||||||
|
const PREVIEW_OPTIONS: PreviewOptions = {
|
||||||
|
rehypePlugins: [[rehypeSanitize]],
|
||||||
|
components: { input: NoteCheckbox },
|
||||||
|
};
|
||||||
|
|
||||||
const NOTE_COMMANDS = [
|
const NOTE_COMMANDS = [
|
||||||
commands.bold,
|
commands.bold,
|
||||||
commands.italic,
|
commands.italic,
|
||||||
@@ -83,6 +101,32 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
[scheduleSave, content],
|
[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>) => {
|
||||||
|
if (isEditing) return;
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const boxes = Array.from(
|
||||||
|
event.currentTarget.querySelectorAll<HTMLInputElement>(
|
||||||
|
'input[type="checkbox"]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const index = boxes.indexOf(target);
|
||||||
|
const next = toggleTaskLine(content, index);
|
||||||
|
if (next === content) return;
|
||||||
|
setContent(next);
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
void save(next, title);
|
||||||
|
},
|
||||||
|
[isEditing, content, title, save],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden" data-color-mode="auto">
|
<div className="flex h-full flex-col overflow-hidden" data-color-mode="auto">
|
||||||
{/* Header: title + pencil toggle */}
|
{/* Header: title + pencil toggle */}
|
||||||
@@ -123,7 +167,11 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content: preview or editor */}
|
{/* Content: preview or editor */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div
|
||||||
|
className="flex-1 overflow-auto"
|
||||||
|
data-testid="note-preview"
|
||||||
|
onClick={handlePreviewClick}
|
||||||
|
>
|
||||||
<MDEditor
|
<MDEditor
|
||||||
data-testid="md-editor"
|
data-testid="md-editor"
|
||||||
value={content}
|
value={content}
|
||||||
@@ -133,9 +181,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
|||||||
hideToolbar={!isEditing}
|
hideToolbar={!isEditing}
|
||||||
height="100%"
|
height="100%"
|
||||||
visibleDragbar={false}
|
visibleDragbar={false}
|
||||||
previewOptions={{
|
previewOptions={PREVIEW_OPTIONS}
|
||||||
rehypePlugins: [[rehypeSanitize]],
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user