feat(workspace): add formatting shortcuts for bold, italic, and links in outliner
This commit is contained in:
1 parent
1d6863824b
commit
a317ce0fee
4 files changed
+285
No files matched your search
@@ -440,6 +440,33 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
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<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
let val = e.target.value;
|
||||
@@ -616,6 +643,108 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
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') {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 <strong> tag', () => {
|
||||
const { container } = render(<div>{renderInlineMarkdown('Text mit **fettem** Wort')}</div>);
|
||||
const strong = container.querySelector('strong');
|
||||
expect(strong).toBeTruthy();
|
||||
expect(strong?.textContent).toBe('fettem');
|
||||
});
|
||||
|
||||
it('renders italic text with <em> tag', () => {
|
||||
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
|
||||
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(<div>{renderInlineMarkdown('Hier ist **fett** und *kursiv* vereint')}</div>);
|
||||
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(<div>{renderInlineMarkdown('[Testseite](https://example.com)')}</div>);
|
||||
const link = container.querySelector('a');
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute('href')).toBe('https://example.com/');
|
||||
expect(link?.textContent).toBe('Testseite');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user