diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74666f9..195922d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,13 @@ 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
+- 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
### Added
diff --git a/README.md b/README.md
index ad68179..5d10d77 100644
--- a/README.md
+++ b/README.md
@@ -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**.
* **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.
+* **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.
diff --git a/frontend/src/features/workspace/CodeBlockEditor.tsx b/frontend/src/features/workspace/CodeBlockEditor.tsx
index 8f314b7..c4e436b 100644
--- a/frontend/src/features/workspace/CodeBlockEditor.tsx
+++ b/frontend/src/features/workspace/CodeBlockEditor.tsx
@@ -347,6 +347,7 @@ export const CodeBlockEditor: React.FC = ({
const copyTimerRef = useRef(null);
const localTextareaRef = useRef(null);
const preRef = useRef(null);
+ const lineNumbersRef = useRef(null);
useEffect(() => {
return () => {
@@ -506,18 +507,42 @@ export const CodeBlockEditor: React.FC = ({
};
const handleScroll = (e: React.UIEvent) => {
+ const target = e.currentTarget;
if (preRef.current) {
- preRef.current.scrollTop = e.currentTarget.scrollTop;
- preRef.current.scrollLeft = e.currentTarget.scrollLeft;
+ preRef.current.scrollTop = target.scrollTop;
+ preRef.current.scrollLeft = target.scrollLeft;
+ }
+ if (lineNumbersRef.current) {
+ lineNumbersRef.current.scrollTop = target.scrollTop;
}
};
+ const handleLineNumbersScroll = (e: React.UIEvent) => {
+ 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 selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
return (
-
+
{/* Code Block Header */}
@@ -584,41 +609,60 @@ export const CodeBlockEditor: React.FC = ({
- {/* Live Highlighted Overlay + Textarea */}
-
-
+ {/* Line Numbers Column */}
+
-
(
+
+ {i + 1}
+
+ ))}
+
+
+ {/* Code Content (Overlay + Textarea) */}
+
);
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__/CodeBlockEditor.test.tsx b/frontend/src/features/workspace/__tests__/CodeBlockEditor.test.tsx
index b116ab0..a107a71 100644
--- a/frontend/src/features/workspace/__tests__/CodeBlockEditor.test.tsx
+++ b/frontend/src/features/workspace/__tests__/CodeBlockEditor.test.tsx
@@ -209,4 +209,55 @@ describe('CodeBlockEditor', () => {
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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');
+ });
});
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/index.css b/frontend/src/index.css
index c413f42..75839b8 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -429,11 +429,11 @@ body {
--code-keyword: #0000ff;
--code-string: #a31515;
--code-number: #098658;
- --code-comment: #008000;
+ --code-comment: #007000;
--code-property: #0451a5;
--code-function: #795e26;
--code-type: #267f99;
- --code-punctuation: #3b3b3b;
+ --code-punctuation: #2e2e2e;
--code-variable: #001080;
}
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');
+ });
+});
diff --git a/frontend/src/themes/theme-light.css b/frontend/src/themes/theme-light.css
index 9d5cb1e..a1539aa 100644
--- a/frontend/src/themes/theme-light.css
+++ b/frontend/src/themes/theme-light.css
@@ -14,24 +14,24 @@
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #f3f4f6;
--bg-panel: #ffffff;
- --bg-panel-elevated: #ebedf0;
- --bg-panel-hover: #e2e5ea;
+ --bg-panel-elevated: #e2e8f0;
+ --bg-panel-hover: #cbd5e1;
--bg-input: #ffffff;
--bg-overlay: rgba(15, 23, 42, 0.45);
- --bg-code: #f1f5f9;
+ --bg-code: #e2e8f0;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #111827;
- --text-muted: #4b5563;
- --text-faint: #9ca3af;
+ --text-muted: #374151;
+ --text-faint: #6b7280;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
- --border-color: rgba(17, 24, 39, 0.13);
- --border-subtle: rgba(17, 24, 39, 0.06);
- --border-strong: rgba(17, 24, 39, 0.28);
+ --border-color: rgba(17, 24, 39, 0.18);
+ --border-subtle: rgba(17, 24, 39, 0.08);
+ --border-strong: rgba(17, 24, 39, 0.32);
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
- --scrollbar-thumb: rgba(17, 24, 39, 0.18);
- --scrollbar-thumb-hover: rgba(17, 24, 39, 0.32);
+ --scrollbar-thumb: rgba(17, 24, 39, 0.22);
+ --scrollbar-thumb-hover: rgba(17, 24, 39, 0.38);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #15803d;
@@ -57,10 +57,10 @@
--code-keyword: #0000ff;
--code-string: #a31515;
--code-number: #098658;
- --code-comment: #008000;
+ --code-comment: #007000;
--code-property: #0451a5;
--code-function: #795e26;
--code-type: #267f99;
- --code-punctuation: #3b3b3b;
+ --code-punctuation: #2e2e2e;
--code-variable: #001080;
}