From a317ce0fee8d1ffa398799159013ce6ded346804 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 16:22:55 +0100 Subject: [PATCH] feat(workspace): add formatting shortcuts for bold, italic, and links in outliner --- CHANGELOG.md | 1 + .../src/features/workspace/OutlinerBlock.tsx | 129 ++++++++++++++++++ .../__tests__/OutlinerBlock.caret.test.tsx | 120 ++++++++++++++++ frontend/src/lib/__tests__/markdown.test.tsx | 35 +++++ 4 files changed, 285 insertions(+) create mode 100644 frontend/src/lib/__tests__/markdown.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c64506c..195922d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] ### Added +- Formatierungs-Kürzel im Outliner: `Strg+B` / `Cmd+B` für fett, `Strg+I` / `Cmd+I` für kursiv (mit Ein-/Ausschalten bei markiertem Text) und `Strg+K` / `Cmd+K` für Links (mit Markierung `[Text](…)` mit vorselektierter Adresse, ohne Markierung Link-Dialog). - Code-Blöcke: Zeilennummernspalte links mit synchronem Scrollen (wächst dynamisch bei 100+ Zeilen, zählt auch leere Endzeilen mit); horizontaler Bildlauf bei langen Zeilen ohne automatischen Zeilenumbruch (`white-space: pre`). ### Fixed diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 03734fc..21e2f19 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -440,6 +440,33 @@ export const OutlinerBlock: React.FC = ({ flushDebounce(newContent); }; + const applyFormattingChange = (newVal: string, selStart: number, selEnd: number) => { + setLocalContent(newVal); + localContentRef.current = newVal; + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = window.setTimeout(() => { + debounceTimerRef.current = null; + onUpdateBlockRef.current({ + ...latestBlockRef.current, + content: newVal, + }); + }, 150); + + const ta = textareaRef.current; + if (ta) { + ta.value = newVal; + ta.setSelectionRange(selStart, selEnd); + } + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.setSelectionRange(selStart, selEnd); + adjustHeight(); + } + }, 0); + }; + const handleContentChange = (e: React.ChangeEvent) => { if (!canEdit) return; let val = e.target.value; @@ -616,6 +643,108 @@ export const OutlinerBlock: React.FC = ({ } if (!canEdit) return; + // Formatting shortcuts (Ctrl/Cmd+B, Ctrl/Cmd+I, Ctrl/Cmd+K) - not in code blocks + const isMod = (e.ctrlKey || e.metaKey) && !e.altKey; + if (isMod && block.type !== 'code' && ['b', 'i', 'k'].includes(e.key.toLowerCase())) { + e.preventDefault(); + e.stopPropagation(); + + const ta = textareaRef.current || e.currentTarget; + const start = ta.selectionStart ?? 0; + const end = ta.selectionEnd ?? 0; + const text = ta.value; + const hasSelection = start < end; + + if (e.key.toLowerCase() === 'k') { + if (!hasSelection) { + setShowLinkModal(true); + return; + } + const selected = text.slice(start, end); + const placeholder = '…'; + const before = text.slice(0, start); + const after = text.slice(end); + const inserted = `[${selected}](${placeholder})`; + const newVal = before + inserted + after; + const selStart = start + 1 + selected.length + 2; + const selEnd = selStart + placeholder.length; + applyFormattingChange(newVal, selStart, selEnd); + return; + } + + const isBold = e.key.toLowerCase() === 'b'; + const delim = isBold ? '**' : '*'; + + if (!hasSelection) { + const pair = isBold ? '****' : '**'; + const newVal = text.slice(0, start) + pair + text.slice(start); + const newPos = start + delim.length; + applyFormattingChange(newVal, newPos, newPos); + return; + } + + const selected = text.slice(start, end); + + if (isBold) { + const isOutside = + start >= 2 && + end + 2 <= text.length && + text.slice(start - 2, start) === '**' && + text.slice(end, end + 2) === '**'; + + const isInside = + selected.length >= 4 && + selected.startsWith('**') && + selected.endsWith('**'); + + if (isOutside) { + const newVal = text.slice(0, start - 2) + selected + text.slice(end + 2); + applyFormattingChange(newVal, start - 2, end - 2); + return; + } + if (isInside) { + const unwrapped = selected.slice(2, -2); + const newVal = text.slice(0, start) + unwrapped + text.slice(end); + applyFormattingChange(newVal, start, start + unwrapped.length); + return; + } + + const newVal = text.slice(0, start) + '**' + selected + '**' + text.slice(end); + applyFormattingChange(newVal, start + 2, end + 2); + return; + } else { + const isOutside = + start >= 1 && + end + 1 <= text.length && + text[start - 1] === '*' && + text[end] === '*' && + (start < 2 || text[start - 2] !== '*') && + (end + 1 >= text.length || text[end + 1] !== '*'); + + const isInside = + selected.length >= 2 && + selected.startsWith('*') && + selected.endsWith('*') && + (!selected.startsWith('**') || !selected.endsWith('**') || selected.length < 4); + + if (isOutside) { + const newVal = text.slice(0, start - 1) + selected + text.slice(end + 1); + applyFormattingChange(newVal, start - 1, end - 1); + return; + } + if (isInside) { + const unwrapped = selected.slice(1, -1); + const newVal = text.slice(0, start) + unwrapped + text.slice(end); + applyFormattingChange(newVal, start, start + unwrapped.length); + return; + } + + const newVal = text.slice(0, start) + '*' + selected + '*' + text.slice(end); + applyFormattingChange(newVal, start + 1, end + 1); + return; + } + } + // Slash command navigation if (slashSearch !== null) { if (e.key === 'Escape') { diff --git a/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx b/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx index c5b4d28..39c493b 100644 --- a/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx +++ b/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx @@ -255,3 +255,123 @@ describe('slash command menu and outliner fixes', () => { 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'); + }); +}); diff --git a/frontend/src/lib/__tests__/markdown.test.tsx b/frontend/src/lib/__tests__/markdown.test.tsx new file mode 100644 index 0000000..b738108 --- /dev/null +++ b/frontend/src/lib/__tests__/markdown.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { renderInlineMarkdown } from '../markdown'; + +describe('renderInlineMarkdown', () => { + it('renders bold text with tag', () => { + const { container } = render(
{renderInlineMarkdown('Text mit **fettem** Wort')}
); + const strong = container.querySelector('strong'); + expect(strong).toBeTruthy(); + expect(strong?.textContent).toBe('fettem'); + }); + + it('renders italic text with tag', () => { + const { container } = render(
{renderInlineMarkdown('Text mit *kursivem* Wort')}
); + const em = container.querySelector('em'); + expect(em).toBeTruthy(); + expect(em?.textContent).toBe('kursivem'); + }); + + it('renders both bold and italic text in one paragraph', () => { + const { container } = render(
{renderInlineMarkdown('Hier ist **fett** und *kursiv* vereint')}
); + const strong = container.querySelector('strong'); + const em = container.querySelector('em'); + expect(strong?.textContent).toBe('fett'); + expect(em?.textContent).toBe('kursiv'); + }); + + it('renders links correctly', () => { + const { container } = render(
{renderInlineMarkdown('[Testseite](https://example.com)')}
); + const link = container.querySelector('a'); + expect(link).toBeTruthy(); + expect(link?.getAttribute('href')).toBe('https://example.com/'); + expect(link?.textContent).toBe('Testseite'); + }); +});