import React from 'react'; import { describe, it, expect, afterEach, vi } from 'vitest'; import { render, fireEvent } from '@testing-library/react'; import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock'; import { DocumentBlock } from '../../../lib/types'; describe('mapRenderedOffsetToRaw', () => { it('is the identity for plain text', () => { expect(mapRenderedOffsetToRaw('Hallo Welt', 'Hallo Welt', 4)).toBe(4); }); it('skips bold markers', () => { expect(mapRenderedOffsetToRaw('Hallo **Welt** du', 'Hallo Welt du', 8)).toBe(10); }); it('maps behind closing markers', () => { expect(mapRenderedOffsetToRaw('a **b** c', 'a b c', 4)).toBe(8); }); it('maps into a link label', () => { expect(mapRenderedOffsetToRaw('x [Seite](https://a.de) y', 'x Seite y', 4)).toBe(5); }); it('handles the empty placeholder', () => { expect(mapRenderedOffsetToRaw('', '​', 1)).toBe(0); }); }); describe('clicking into an unfocused bullet', () => { afterEach(() => { delete (document as any).caretRangeFromPoint; }); it('places the caret at the click position instead of the end', () => { const block = { id: 'b1', type: 'bullet', content: 'Hallo **Welt** du', level: 0, position: 1 } as DocumentBlock; const { container } = render( {}} onCreateBlockBelow={() => {}} onDeleteBlock={() => {}} onIndentBlock={() => {}} onNavigateFocus={() => {}} registerTextareaRef={() => {}} /> ); const ta = container.querySelector('textarea') as HTMLTextAreaElement; const strong = container.querySelector('strong') as HTMLElement; // rendered "Welt" // jsdom has no layout: pretend the click hit between "We" and "lt" (document as any).caretRangeFromPoint = () => { const r = document.createRange(); r.setStart(strong.firstChild!, 2); return r; }; fireEvent.click(strong); expect(document.activeElement).toBe(ta); expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du" }); }); function renderBlock(block: DocumentBlock, extra: Record = {}) { return render( {}} onCreateBlockBelow={() => {}} onDeleteBlock={() => {}} onIndentBlock={() => {}} onNavigateFocus={() => {}} registerTextareaRef={() => {}} {...extra} /> ); } describe('undo shortcut', () => { it('saves pending typing first, then undoes', () => { const calls: string[] = []; const block = { id: 'b1', type: 'bullet', content: 'Alt', level: 0 } as DocumentBlock; const { container } = renderBlock(block, { onUpdateBlock: (b: DocumentBlock) => calls.push(`update:${b.content}`), onUndo: () => calls.push('undo'), onRedo: () => calls.push('redo'), }); const ta = container.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); fireEvent.change(ta, { target: { value: 'Alt neu' } }); fireEvent.keyDown(ta, { key: 'z', ctrlKey: true }); fireEvent.keyDown(ta, { key: 'z', ctrlKey: true, shiftKey: true }); fireEvent.keyDown(ta, { key: 'y', metaKey: true }); expect(calls).toEqual(['update:Alt neu', 'undo', 'redo', 'redo']); }); }); describe('conflict copies', () => { it('show who lost and offer accept/discard', () => { const onResolve = vi.fn(); const block = { id: 'cf', type: 'bullet', content: 'Meine Version', level: 0, conflictOf: 'b1', conflictAuthor: 'Ben' } as DocumentBlock; const { getByText } = renderBlock(block, { onResolveConflict: onResolve }); expect(getByText(/Version von Ben/)).toBeTruthy(); fireEvent.click(getByText('Übernehmen')); fireEvent.click(getByText('Verwerfen')); expect(onResolve.mock.calls).toEqual([ ['cf', true], ['cf', false], ]); }); }); describe('slash command menu and outliner fixes', () => { it('does not open the slash menu for URLs like https://a.de/b/ but opens for Text /co and closes on Escape or Backspace', () => { const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock; const { container, queryByText } = renderBlock(block); const ta = container.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); fireEvent.change(ta, { target: { value: 'https://a.de/b/' } }); expect(queryByText('Befehl auswählen')).toBeNull(); fireEvent.change(ta, { target: { value: 'Text /co' } }); expect(queryByText('Befehl auswählen')).toBeTruthy(); expect(queryByText('/code')).toBeTruthy(); // Removing the slash closes the menu fireEvent.change(ta, { target: { value: 'Text ' } }); expect(queryByText('Befehl auswählen')).toBeNull(); // Even when no command matches, Escape closes the menu fireEvent.change(ta, { target: { value: '/zzzznotfound' } }); expect(queryByText('Kein Befehl gefunden')).toBeTruthy(); fireEvent.keyDown(ta, { key: 'Escape' }); expect(queryByText('Befehl auswählen')).toBeNull(); }); it('scrolls the active slash command button into view on ArrowDown', () => { const scrollSpy = vi.fn(); HTMLElement.prototype.scrollIntoView = scrollSpy; const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock; const { container } = renderBlock(block); const ta = container.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); fireEvent.change(ta, { target: { value: '/' } }); scrollSpy.mockClear(); fireEvent.keyDown(ta, { key: 'ArrowDown' }); expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' }); }); it('creates a new block on Enter at the last empty top-level bullet, after outdenting if indented', () => { const onIndent = vi.fn(); const onCreateBelow = vi.fn(); const indented = { id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock; const { container, rerender } = render( {}} onCreateBlockBelow={onCreateBelow} onDeleteBlock={() => {}} onIndentBlock={onIndent} onNavigateFocus={() => {}} registerTextareaRef={() => {}} /> ); const ta = container.querySelector('textarea') as HTMLTextAreaElement; fireEvent.keyDown(ta, { key: 'Enter' }); expect(onIndent).toHaveBeenCalledWith('b1', -1); expect(onCreateBelow).not.toHaveBeenCalled(); rerender( {}} onCreateBlockBelow={onCreateBelow} onDeleteBlock={() => {}} onIndentBlock={onIndent} onNavigateFocus={() => {}} registerTextareaRef={() => {}} /> ); fireEvent.keyDown(ta, { key: 'Enter' }); expect(onCreateBelow).toHaveBeenCalledWith('b1', ''); }); it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => { const updates: DocumentBlock[] = []; const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock; const { container, getByText, getByRole, getByPlaceholderText, rerender } = renderBlock(block, { onUpdateBlock: (u: DocumentBlock) => updates.push(u), }); const ta = container.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); fireEvent.change(ta, { target: { value: '/link' } }); fireEvent.keyDown(ta, { key: 'Enter' }); fireEvent.click(getByText('Web-Link')); fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } }); fireEvent.click(getByRole('button', { name: 'Link einfügen' })); expect(updates[updates.length - 1]?.content).toBe('https://example.com/test'); // Non-empty bullet keeps [Text](url) rerender( updates.push(u)} onCreateBlockBelow={() => {}} onDeleteBlock={() => {}} onIndentBlock={() => {}} onNavigateFocus={() => {}} registerTextareaRef={() => {}} /> ); ta.focus(); fireEvent.change(ta, { target: { value: 'Siehe /link' } }); fireEvent.keyDown(ta, { key: 'Enter' }); fireEvent.click(getByText('Web-Link')); fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } }); fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/doku' } }); fireEvent.click(getByRole('button', { name: 'Link einfügen' })); expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)'); }); }); describe('formatting shortcuts (Teil C)', () => { it('toggles bold with and without selection (Ctrl+B and Cmd+B)', () => { const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock; const { container } = renderBlock(block); const ta = container.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); // 1. With selection: wraps in ** ta.setSelectionRange(6, 10); // "welt" const event1 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true }); ta.dispatchEvent(event1); expect(event1.defaultPrevented).toBe(true); expect(ta.value).toBe('hallo **welt**'); expect(ta.selectionStart).toBe(8); expect(ta.selectionEnd).toBe(12); // 2. With selection where delimiters are directly outside: unwraps ** const event2 = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }); ta.dispatchEvent(event2); expect(event2.defaultPrevented).toBe(true); expect(ta.value).toBe('hallo welt'); expect(ta.selectionStart).toBe(6); expect(ta.selectionEnd).toBe(10); // 3. With selection including delimiters: unwraps ** ta.value = 'hallo **welt**'; ta.setSelectionRange(6, 14); // "**welt**" const event3 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true }); ta.dispatchEvent(event3); expect(ta.value).toBe('hallo welt'); expect(ta.selectionStart).toBe(6); expect(ta.selectionEnd).toBe(10); // 4. Without selection: inserts **** and places cursor between ta.value = 'start'; ta.setSelectionRange(5, 5); const event4 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true }); ta.dispatchEvent(event4); expect(ta.value).toBe('start****'); expect(ta.selectionStart).toBe(7); expect(ta.selectionEnd).toBe(7); }); it('toggles italic with and without selection (Ctrl+I)', () => { const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock; const { container } = renderBlock(block); const ta = container.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); // 1. With selection: wraps in * ta.setSelectionRange(6, 10); // "welt" fireEvent.keyDown(ta, { key: 'i', ctrlKey: true }); expect(ta.value).toBe('hallo *welt*'); expect(ta.selectionStart).toBe(7); expect(ta.selectionEnd).toBe(11); // 2. Toggles off when already italic fireEvent.keyDown(ta, { key: 'i', ctrlKey: true }); expect(ta.value).toBe('hallo welt'); expect(ta.selectionStart).toBe(6); expect(ta.selectionEnd).toBe(10); // 3. Without selection: inserts ** and places cursor between fireEvent.change(ta, { target: { value: 'a' } }); ta.setSelectionRange(1, 1); fireEvent.keyDown(ta, { key: 'i', ctrlKey: true }); expect(ta.value).toBe('a**'); expect(ta.selectionStart).toBe(2); expect(ta.selectionEnd).toBe(2); }); it('handles Ctrl+K: with selection wraps in [Text](…) and selects placeholder, without selection opens modal', () => { const block = { id: 'b1', type: 'bullet', content: 'besuche mindsift heute', level: 0 } as DocumentBlock; const { container, queryByText } = renderBlock(block); const ta = container.querySelector('textarea') as HTMLTextAreaElement; ta.focus(); // 1. With selection ta.setSelectionRange(8, 16); // "mindsift" const eventWithSel = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true }); ta.dispatchEvent(eventWithSel); expect(eventWithSel.defaultPrevented).toBe(true); expect(ta.value).toBe('besuche [mindsift](…) heute'); // Placeholder "…" is selected expect(ta.selectionStart).toBe(19); expect(ta.selectionEnd).toBe(20); expect(ta.value.slice(ta.selectionStart, ta.selectionEnd)).toBe('…'); // 2. Without selection: opens LinkModal ta.setSelectionRange(0, 0); expect(queryByText('Link einfügen')).toBeNull(); fireEvent.keyDown(ta, { key: 'k', ctrlKey: true }); expect(queryByText('Link einfügen')).toBeTruthy(); }); it('does nothing in code blocks or for read-only readers', () => { // In code block const codeBlock = { id: 'c1', type: 'code', content: 'const x = 1;', level: 0 } as DocumentBlock; const codeView = renderBlock(codeBlock); const codeTa = codeView.container.querySelector('textarea') as HTMLTextAreaElement; codeTa.focus(); codeTa.setSelectionRange(6, 7); // "x" const codeEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true }); codeTa.dispatchEvent(codeEvent); expect(codeEvent.defaultPrevented).toBe(false); expect(codeTa.value).toBe('const x = 1;'); // For readers (!canEdit) const bulletBlock = { id: 'b1', type: 'bullet', content: 'text', level: 0 } as DocumentBlock; const readerView = renderBlock(bulletBlock, { canEdit: false }); const readerTa = readerView.container.querySelector('textarea') as HTMLTextAreaElement; readerTa.focus(); readerTa.setSelectionRange(0, 4); const readerEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true }); readerTa.dispatchEvent(readerEvent); expect(readerEvent.defaultPrevented).toBe(false); expect(readerTa.value).toBe('text'); }); });