Merge branch 'feat/editor-polish' into dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8ecde4872d
9 files changed
+435
-48
No files matched your search
@@ -6,6 +6,13 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
- Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert.
|
||||||
|
|
||||||
## [0.8.0] - 2026-10-30
|
## [0.8.0] - 2026-10-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||||
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
|
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
|
||||||
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
|
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
|
||||||
|
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail/Discord.
|
||||||
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
|
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
const copyTimerRef = useRef<number | null>(null);
|
const copyTimerRef = useRef<number | null>(null);
|
||||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const preRef = useRef<HTMLPreElement | null>(null);
|
const preRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -506,18 +507,42 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
if (preRef.current) {
|
if (preRef.current) {
|
||||||
preRef.current.scrollTop = e.currentTarget.scrollTop;
|
preRef.current.scrollTop = target.scrollTop;
|
||||||
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
preRef.current.scrollLeft = target.scrollLeft;
|
||||||
|
}
|
||||||
|
if (lineNumbersRef.current) {
|
||||||
|
lineNumbersRef.current.scrollTop = target.scrollTop;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLineNumbersScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (localTextareaRef.current) {
|
||||||
|
localTextareaRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
if (preRef.current) {
|
||||||
|
preRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLineNumbersWheel = (e: React.WheelEvent) => {
|
||||||
|
if (localTextareaRef.current) {
|
||||||
|
localTextareaRef.current.scrollTop += e.deltaY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineCount = useMemo(() => {
|
||||||
|
return currentContent.split('\n').length;
|
||||||
|
}, [currentContent]);
|
||||||
|
|
||||||
const rawLang = block.lang || '';
|
const rawLang = block.lang || '';
|
||||||
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
|
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
|
||||||
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-code border border-border rounded-btn my-0.5 overflow-x-auto max-w-full">
|
<div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden">
|
||||||
{/* Code Block Header */}
|
{/* Code Block Header */}
|
||||||
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
|
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
@@ -584,41 +609,60 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Live Highlighted Overlay + Textarea */}
|
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
|
||||||
<div className="relative w-full overflow-x-auto">
|
<div className="flex relative w-full overflow-hidden">
|
||||||
<pre
|
{/* Line Numbers Column */}
|
||||||
ref={preRef}
|
<div
|
||||||
|
ref={lineNumbersRef}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] pointer-events-none select-none overflow-hidden"
|
onScroll={handleLineNumbersScroll}
|
||||||
|
onWheel={handleLineNumbersWheel}
|
||||||
|
data-testid="code-line-numbers"
|
||||||
|
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right border-r border-border/40 shrink-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<code
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
<div key={i + 1} data-line-number={i + 1}>
|
||||||
dangerouslySetInnerHTML={{
|
{i + 1}
|
||||||
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Content (Overlay + Textarea) */}
|
||||||
|
<div className="relative flex-1 min-w-0 overflow-hidden">
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre pointer-events-none select-none overflow-hidden"
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
<textarea
|
||||||
|
ref={(el) => {
|
||||||
|
localTextareaRef.current = el;
|
||||||
|
if (textareaRef) textareaRef.current = el;
|
||||||
|
registerTextareaRef?.(block.id, el);
|
||||||
}}
|
}}
|
||||||
|
rows={1}
|
||||||
|
value={currentContent}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={onTextareaChange}
|
||||||
|
onKeyDown={onTextareaKeyDown}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
style={{ caretColor: 'var(--text-main)' }}
|
||||||
|
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-x-auto"
|
||||||
/>
|
/>
|
||||||
</pre>
|
</div>
|
||||||
<textarea
|
|
||||||
ref={(el) => {
|
|
||||||
localTextareaRef.current = el;
|
|
||||||
if (textareaRef) textareaRef.current = el;
|
|
||||||
registerTextareaRef?.(block.id, el);
|
|
||||||
}}
|
|
||||||
rows={1}
|
|
||||||
value={currentContent}
|
|
||||||
readOnly={!canEdit}
|
|
||||||
spellCheck={false}
|
|
||||||
autoCapitalize="off"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onChange={onTextareaChange}
|
|
||||||
onKeyDown={onTextareaKeyDown}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
style={{ caretColor: 'var(--text-main)' }}
|
|
||||||
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -440,6 +440,33 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
flushDebounce(newContent);
|
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>) => {
|
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
let val = e.target.value;
|
let val = e.target.value;
|
||||||
@@ -616,6 +643,108 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
if (!canEdit) return;
|
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
|
// Slash command navigation
|
||||||
if (slashSearch !== null) {
|
if (slashSearch !== null) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|||||||
@@ -209,4 +209,55 @@ describe('CodeBlockEditor', () => {
|
|||||||
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
|
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
|
||||||
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
|
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders line numbers column with aria-hidden, user-select: none, and correct line count including trailing empty lines and 100+ lines', () => {
|
||||||
|
// 1. Single empty line
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<CodeBlockEditor block={makeCodeBlock({ id: 'code-empty', content: '' })} content="" canEdit />
|
||||||
|
);
|
||||||
|
const getLineContainer = () => container.querySelector('[data-testid="code-line-numbers"]') as HTMLElement;
|
||||||
|
expect(getLineContainer()).toBeTruthy();
|
||||||
|
expect(getLineContainer().getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(getLineContainer().className).toContain('select-none');
|
||||||
|
|
||||||
|
let lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(1);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
|
||||||
|
// 2. Trailing empty line gives an extra line number (e.g. line 1 + empty line 2)
|
||||||
|
rerender(
|
||||||
|
<CodeBlockEditor
|
||||||
|
block={makeCodeBlock({ id: 'code-trailing', content: `first line
|
||||||
|
` })}
|
||||||
|
content={`first line
|
||||||
|
`}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(2);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
expect(lineEls[1].textContent?.trim()).toBe('2');
|
||||||
|
|
||||||
|
// 3. 120 lines (100+ lines test)
|
||||||
|
const longCode = Array.from({ length: 120 }, (_, i) => `const x_${i + 1} = ${i + 1};`).join('\n');
|
||||||
|
rerender(
|
||||||
|
<CodeBlockEditor
|
||||||
|
block={makeCodeBlock({ id: 'code-long', content: longCode })}
|
||||||
|
content={longCode}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(120);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
expect(lineEls[99].textContent?.trim()).toBe('100');
|
||||||
|
expect(lineEls[119].textContent?.trim()).toBe('120');
|
||||||
|
|
||||||
|
// Textarea has whitespace-pre and overlay has whitespace-pre
|
||||||
|
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
const pre = container.querySelector('pre') as HTMLPreElement;
|
||||||
|
expect(textarea.className).toContain('whitespace-pre');
|
||||||
|
expect(pre.className).toContain('whitespace-pre');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -255,3 +255,123 @@ describe('slash command menu and outliner fixes', () => {
|
|||||||
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -429,11 +429,11 @@ body {
|
|||||||
--code-keyword: #0000ff;
|
--code-keyword: #0000ff;
|
||||||
--code-string: #a31515;
|
--code-string: #a31515;
|
||||||
--code-number: #098658;
|
--code-number: #098658;
|
||||||
--code-comment: #008000;
|
--code-comment: #007000;
|
||||||
--code-property: #0451a5;
|
--code-property: #0451a5;
|
||||||
--code-function: #795e26;
|
--code-function: #795e26;
|
||||||
--code-type: #267f99;
|
--code-type: #267f99;
|
||||||
--code-punctuation: #3b3b3b;
|
--code-punctuation: #2e2e2e;
|
||||||
--code-variable: #001080;
|
--code-variable: #001080;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,24 +14,24 @@
|
|||||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
||||||
--bg-app: #f3f4f6;
|
--bg-app: #f3f4f6;
|
||||||
--bg-panel: #ffffff;
|
--bg-panel: #ffffff;
|
||||||
--bg-panel-elevated: #ebedf0;
|
--bg-panel-elevated: #e2e8f0;
|
||||||
--bg-panel-hover: #e2e5ea;
|
--bg-panel-hover: #cbd5e1;
|
||||||
--bg-input: #ffffff;
|
--bg-input: #ffffff;
|
||||||
--bg-overlay: rgba(15, 23, 42, 0.45);
|
--bg-overlay: rgba(15, 23, 42, 0.45);
|
||||||
--bg-code: #f1f5f9;
|
--bg-code: #e2e8f0;
|
||||||
|
|
||||||
/* --- 3. TEXT & KONTRASTE --- */
|
/* --- 3. TEXT & KONTRASTE --- */
|
||||||
--text-main: #111827;
|
--text-main: #111827;
|
||||||
--text-muted: #4b5563;
|
--text-muted: #374151;
|
||||||
--text-faint: #9ca3af;
|
--text-faint: #6b7280;
|
||||||
|
|
||||||
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
--border-color: rgba(17, 24, 39, 0.13);
|
--border-color: rgba(17, 24, 39, 0.18);
|
||||||
--border-subtle: rgba(17, 24, 39, 0.06);
|
--border-subtle: rgba(17, 24, 39, 0.08);
|
||||||
--border-strong: rgba(17, 24, 39, 0.28);
|
--border-strong: rgba(17, 24, 39, 0.32);
|
||||||
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
|
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||||
--scrollbar-thumb: rgba(17, 24, 39, 0.18);
|
--scrollbar-thumb: rgba(17, 24, 39, 0.22);
|
||||||
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.32);
|
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.38);
|
||||||
|
|
||||||
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||||
--color-success: #15803d;
|
--color-success: #15803d;
|
||||||
@@ -57,10 +57,10 @@
|
|||||||
--code-keyword: #0000ff;
|
--code-keyword: #0000ff;
|
||||||
--code-string: #a31515;
|
--code-string: #a31515;
|
||||||
--code-number: #098658;
|
--code-number: #098658;
|
||||||
--code-comment: #008000;
|
--code-comment: #007000;
|
||||||
--code-property: #0451a5;
|
--code-property: #0451a5;
|
||||||
--code-function: #795e26;
|
--code-function: #795e26;
|
||||||
--code-type: #267f99;
|
--code-type: #267f99;
|
||||||
--code-punctuation: #3b3b3b;
|
--code-punctuation: #2e2e2e;
|
||||||
--code-variable: #001080;
|
--code-variable: #001080;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user