Merge branch 'feat/outliner-polish' into dev

Offer /number at start or end like the other block commands and start a
numbered list when typing "1. " into an empty bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 19:44:20 +01:00
commit 7a90e6dae3
8 files changed
+663 -195

No files matched your search

+12 -1
View File
@@ -7,14 +7,22 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
### Added
- Nummerierte Listen im Outliner: `/number` (auch `/nummer`, `/liste`); Enter setzt die Liste fort, Enter in einem leeren Punkt beendet sie. Eingerückte Listen zählen mit Buchstaben (a., b.), die nächste Ebene römisch (i., ii.), danach wieder mit Zahlen. Export und Import als Markdown (`1.`).
- Nummerierte Listen im Outliner: `/number` (auch `/nummer`, `/liste`) oder `1. ` am Anfang eines leeren Punkts; Enter setzt die Liste fort, Enter in einem leeren Punkt beendet sie. Eingerückte Listen zählen mit Buchstaben (a., b.), die nächste Ebene römisch (i., ii.), danach wieder mit Zahlen. Export und Import als Markdown (`1.`).
- **Journal** im Workspace: ganz oben in der Seitenleiste; eine Notiz pro Tag mit festem Datumstitel, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach). Solange das Journal offen ist, zeigt ein Kalender mit Monats- und Jahresauswahl den Tag, bei dem man gerade ist; Tage mit Einträgen haben einen Punkt, ein Klick springt hin (und legt den Tag bei Bedarf an). „Teilen“ gilt dem gerade sichtbaren Tag. Am Handy klappt der Kalender über die Kopfzeile auf. Unter Darstellung pro Gerät abschaltbar. API: `GET /journal`, `POST /journal/{date}`.
- Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse).
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
- Triage-Deck: Touch-freundliche Schnellaktions-Leiste unter der aktiven mobilen Karte (Wegwerfen, Liste, Notiz, Aufgabe) mit Daumenbedienung (≥ 44 px).
### Changed
- Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer.
- Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile.
- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz.
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
- Quick Dump (Instant Capture): Platzhaltertexte gekürzt, vertikales Scrolling im einzeiligen Zustand unterbunden und Absende-Pfeil nach unten ausgerichtet.
- Zähler-Badges (`CountBadge`): Akzent-Badge über `--accent-dim` mit Rand, Muted-Badge über `--bg-panel-elevated` und `--text-muted` für hohe Lesbarkeit in allen Themes.
- Workspace: Popover-Dialoge (`FormSheet` mit Anker) öffnen zentriert unter dem Mauszeiger mit Rand-Schutz (Viewport-Clamping).
### Fixed
- Notizen: Eingefügter Text mit Zeilenumbrüchen, der in einem einzigen Punkt landet (z. B. ein Absatz), verschwand – er wird jetzt übernommen. Leerzeilen trennen eingefügte Absätze in eigene Punkte, nummerierte Markdown-Listen bleiben nummeriert (auch mit 3 oder 4 Leerzeichen Einrückung), umbrochene Zeilen gehören zum Punkt darüber.
@@ -22,6 +30,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite.
- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen.
- Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe).
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
- Outliner: Aufgaben-Badges und Drei-Punkte-Menü aus dem Fließtext-Container entkoppelt, um störende Zeilenumbrüche am Zeilenende zu beheben.
- Outliner: Trennlinien (`divider`) können fokussiert und per `Backspace` oder `Entf` direkt in einen leeren Textpunkt umgewandelt werden.
- Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche.
- Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen.
+7 -2
View File
@@ -36,7 +36,12 @@ export const FormSheet: React.FC<FormSheetProps> = ({
}, [onClose]);
const desktopAnchor = anchor
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
? ({
'--sheet-left': `${anchor.x}px`,
'--sheet-top': `${anchor.y}px`,
'--sheet-x': `clamp(12px, calc(${anchor.x}px - 8.25rem), calc(100vw - 16.5rem - 12px))`,
'--sheet-y': `calc(${anchor.y}px + 8px)`,
} as React.CSSProperties)
: undefined;
return (
@@ -54,7 +59,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
anchor
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-x)] sm:top-[var(--sheet-y)]'
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
}`}
>
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
import {
Mic,
MicOff,
ArrowUp,
ArrowDown,
Sparkles,
Calendar,
Check,
@@ -73,6 +73,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
});
const isListening = voice.state === 'listening';
const isTranscribing = voice.state === 'transcribing';
const [isOverMaxHeight, setIsOverMaxHeight] = useState(false);
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current;
@@ -82,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const scrollH = targetEl.scrollHeight;
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
setIsOverMaxHeight(scrollH > 140);
} else {
setIsMultiline(false);
setIsOverMaxHeight(false);
}
};
@@ -267,16 +270,18 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
: isTranscribing
? 'Wird erkannt…'
: hideTargetPicker || fixedTarget === 'inbox'
? 'Quick Dump erfassen… (Enter zum Speichern)'
? 'Quick Dump erfassen…'
: target === 'task'
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
? 'Aufgabe erfassen…'
: target === 'list_item'
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
: target === 'document'
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
? 'Notiz erfassen…'
: 'Quick Dump…'
}
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto"
className={`w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
}`}
style={{ minHeight: '22px', maxHeight: '140px' }}
autoComplete="off"
/>
@@ -360,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</>
) : (
<>
<ArrowUp className="w-3 h-3" />
<ArrowDown className="w-3 h-3" />
<span className="inline">
{hideTargetPicker || (fixedTarget || target) === 'inbox'
? 'Quick Dump'
+67 -1
View File
@@ -725,7 +725,73 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</div>
)}
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
{/* Mobile Quick Action Buttons under the card (Touch targets >= 44px) */}
<div className="grid grid-cols-4 gap-1.5 sm:hidden">
<button
type="button"
onClick={() => handleAction('discard')}
title="Wegwerfen"
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
>
<X className="w-4 h-4 text-muted flex-shrink-0" />
<span>Wegwerfen</span>
</button>
<button
type="button"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
});
return;
}
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf eine geteilte Liste setzen'
}
className={`btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer ${
writableLists.length === 0
? 'opacity-40 cursor-not-allowed'
: showListSelector
? 'border-accent text-accent bg-accent-dim'
: ''
}`}
>
<ShoppingCart className="w-4 h-4 text-accent flex-shrink-0" />
<span className="truncate max-w-full">{singleWritableList ? singleWritableList.title : 'Liste'}</span>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'document' })}
title="In Notiz umwandeln"
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
>
<FileText className="w-4 h-4 text-accent flex-shrink-0" />
<span>Notiz</span>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'task' })}
title="Als Aufgabe übernehmen"
className="btn-primary h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] font-semibold whitespace-nowrap touch-manipulation cursor-pointer"
>
<CheckSquare className="w-4 h-4 flex-shrink-0" />
<span>Aufgabe</span>
</button>
</div>
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
<div className="hidden sm:flex items-center gap-1.5">
<button
onClick={() => handleAction('discard')}
@@ -160,4 +160,26 @@ describe('TriageDeck single-list shortcut', () => {
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
});
it('renders mobile quick action buttons and triggers discard', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const [mobileDiscard] = screen.getAllByRole('button', { name: /^Wegwerfen$/i });
fireEvent.click(mobileDiscard);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'discard', expect.objectContaining({}));
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
});
+431 -177
View File
@@ -25,6 +25,7 @@ import {
Mic,
AudioLines,
FileOutput,
Type,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
@@ -57,6 +58,7 @@ const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: <ListOrdered className="w-3.5 h-3.5" />, aliases: ['nummer', 'liste', 'ol'] },
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'bullet', label: '/text', desc: 'In Standardtext umwandeln', icon: <Type className="w-3.5 h-3.5" />, aliases: ['p', 'paragraph', 'bullet', 'standard'] },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
@@ -161,7 +163,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const activeFocus = isFocused || isLocallyFocused;
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashMatchInfo, setSlashMatchInfo] = useState<{ query: string; slashStart: number; cursor: number } | null>(null);
const slashSearch = slashMatchInfo ? slashMatchInfo.query : null;
const linkBeforeRef = useRef<string>('');
const linkAfterRef = useRef<string>('');
const dictationBeforeRef = useRef<string>('');
const dictationAfterRef = useRef<string>('');
const slashMenuRef = useRef<HTMLDivElement | null>(null);
const todoMenuRef = useRef<HTMLDivElement | null>(null);
const blurSlashTimerRef = useRef<number | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
@@ -277,9 +287,48 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
};
}, []);
// Close slash command menu on click outside
useEffect(() => {
if (!slashMatchInfo) return;
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (
slashMenuRef.current &&
!slashMenuRef.current.contains(target) &&
textareaRef.current &&
!textareaRef.current.contains(target)
) {
setSlashMatchInfo(null);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [slashMatchInfo]);
// Close todo menu on click outside
useEffect(() => {
if (!showTodoMenu) return;
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (todoMenuRef.current && !todoMenuRef.current.contains(target)) {
setShowTodoMenu(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [showTodoMenu]);
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
@@ -363,22 +412,55 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, [adjustHeight, block.type]);
const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
if (!slashMatchInfo) return [];
const q = slashMatchInfo.query.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter(
(c) =>
(!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
// The bullet's text becomes the title of the new note
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
(c.label.toLowerCase().includes(cmdQuery) ||
const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart);
const textAfterCursor = localContent.slice(slashMatchInfo.cursor);
const restOfBlock = (textBeforeSlash + textAfterCursor).trim();
const isRestEmpty = restOfBlock === '';
const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === '';
return SLASH_COMMANDS.filter((c) => {
const matchesQuery =
c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
);
}, [slashSearch, localContent, onExtractBlock]);
(c.aliases || []).some((a) => a.includes(cmdQuery));
if (!matchesQuery) return false;
// /link and /dictation everywhere
if (c.type === 'link') return true;
if (c.type === 'dictation') {
return ['server', 'browser'].includes(currentVoiceConfig().mode);
}
// /divider, /image, /audio, /code only when rest of block is empty
if (c.type === 'divider' || c.type === 'image' || c.type === 'audio' || c.type === 'code') {
return isRestEmpty;
}
// /extract only when note has content and is at start/end
if (c.type === 'extract') {
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock;
}
// /h1, /h2, /h3, /todo, /callout, /text (type 'bullet') at start or at end
if (
c.type === 'h1' ||
c.type === 'h2' ||
c.type === 'h3' ||
c.type === 'todo' ||
c.type === 'numbered' ||
c.type === 'callout' ||
c.type === 'bullet'
) {
return isAtStartOrEnd;
}
return true;
});
}, [slashMatchInfo, localContent, onExtractBlock]);
useEffect(() => {
if (slashSearch === null) return;
@@ -386,15 +468,37 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, [slashIndex, slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
const slashStart = slashMatchInfo ? slashMatchInfo.slashStart : 0;
const slashEnd = slashMatchInfo ? slashMatchInfo.cursor : localContent.length;
setSlashMatchInfo(null);
const before = localContent.slice(0, slashStart);
const after = localContent.slice(slashEnd);
const clean = `${before}${after}`.replace(/\s{2,}/g, ' ').trim();
if (cmd.type === 'link') {
linkBeforeRef.current = before;
linkAfterRef.current = after;
setLocalContent(clean);
localContentRef.current = clean;
setShowLinkModal(true);
return;
}
if (cmd.type === 'dictation') {
dictationBeforeRef.current = before;
dictationAfterRef.current = after;
setLocalContent(clean);
localContentRef.current = clean;
flushDebounce(clean);
setMediaPicker('dictation');
return;
}
if (cmd.type === 'extract') {
setLocalContent(clean);
localContentRef.current = clean;
@@ -402,31 +506,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onExtractBlock?.(block.id);
return;
}
if (cmd.media) {
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
setLocalContent('');
localContentRef.current = '';
flushDebounce('');
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
setMediaPicker(cmd.type as 'image' | 'audio');
return;
}
const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i);
const langOverride =
cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
: undefined;
setLocalContent(clean);
localContentRef.current = clean;
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
flushDebounce(clean, {
type: cmd.type as BlockType, // media commands returned above
type: cmd.type as BlockType,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}),
});
};
const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false);
if (localContent.trim() === '') {
const b = linkBeforeRef.current ?? '';
const a = linkAfterRef.current ?? '';
if (b.trim() === '' && a.trim() === '') {
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
if (webMatch) {
const bareUrl = webMatch[1];
@@ -436,10 +547,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return;
}
}
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim();
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
@@ -512,6 +622,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
val = val.replace(/^\[[xX]\]\s/, '');
typeOverride = 'todo';
checkedOverride = true;
} else if ((val === '1. ' || val === '1) ') && block.type !== 'numbered') {
// "1. " typed into an empty bullet starts a numbered list
val = '';
typeOverride = 'numbered';
} else if (val.startsWith('# ') && block.type !== 'h1') {
val = val.slice(2);
typeOverride = 'h1';
@@ -521,18 +635,32 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
typeOverride = 'h3';
} else if (
(val.startsWith('/text ') ||
val.startsWith('/p ') ||
val.startsWith('/paragraph ') ||
val.startsWith('/bullet ') ||
val.startsWith('/standard ')) &&
block.type !== 'bullet'
) {
val = val.replace(/^\/(text|p|paragraph|bullet|standard)\s+/, '');
typeOverride = 'bullet';
}
// Check for slash command invocation
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
// Check for slash command invocation at cursor position
const sel = e.currentTarget.selectionStart;
const cursor = typeof sel === 'number' && sel > 0 ? sel : val.length;
const textBefore = val.slice(0, cursor);
const slashMatch = !typeOverride ? textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) {
setSlashSearch(slashMatch[2]);
const slashStart = slashMatch.index! + slashMatch[1].length;
setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor });
setSlashIndex(0);
} else {
setSlashSearch(null);
setSlashMatchInfo(null);
}
} else {
setSlashSearch(null);
setSlashMatchInfo(null);
}
setLocalContent(val);
@@ -548,6 +676,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
type: typeOverride,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: checkedOverride ?? latestBlockRef.current.checked,
...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}),
});
return;
}
@@ -562,6 +691,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
};
const handleFocus = () => {
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
isFocusedRef.current = true;
setIsLocallyFocused(true);
onFocusBlock?.(block.id);
@@ -572,6 +705,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isFocusedRef.current = false;
setIsLocallyFocused(false);
onBlurBlock?.(block.id);
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
}
blurSlashTimerRef.current = window.setTimeout(() => {
setSlashMatchInfo(null);
}, 150);
if (isPastingRef.current) {
isPastingRef.current = false;
return;
@@ -756,7 +895,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (slashSearch !== null) {
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
setSlashMatchInfo(null);
return;
}
if (filteredSlashCommands.length > 0) {
@@ -796,7 +935,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
if (fenceMatch) {
@@ -856,7 +995,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (e.key === 'Backspace' && localContent === '') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -882,7 +1021,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
}
return;
}
@@ -932,6 +1071,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return (
<div
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
block.type === 'h1'
? 'mt-2.5 sm:mt-3'
: block.type === 'h2'
? 'mt-1.5 sm:mt-2'
: ''
} ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
@@ -1071,14 +1216,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<DictationBlock
onDone={(text) => {
setMediaPicker(null);
setLocalContent(text);
localContentRef.current = text;
flushDebounce(text);
const b = dictationBeforeRef.current ?? '';
const a = dictationAfterRef.current ?? '';
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
const newContent = `${b}${spaceBefore}${text}${spaceAfter}${a}`.trim();
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
const pos = (b + spaceBefore + text).length;
ta.setSelectionRange(pos, pos);
});
}}
onCancel={() => {
@@ -1128,8 +1279,43 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}}
/>
) : block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
<div
className="relative py-2 group/divider cursor-pointer w-full"
onClick={() => {
textareaRef.current?.focus();
}}
>
<hr
className={`border-t transition-colors ${
activeFocus ? 'border-accent ring-1 ring-accent/30' : 'border-border'
}`}
/>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value=""
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={() => {}}
onKeyDown={(e) => {
if (!canEdit) return;
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
refocusAfterTypeChangeRef.current = true;
flushDebounce('', { type: 'bullet' });
setLocalContent('');
localContentRef.current = '';
return;
}
handleKeyDown(e);
}}
className="sr-only"
aria-label="Trennlinie"
/>
</div>
) : block.type === 'code' ? (
<CodeBlockEditor
@@ -1169,7 +1355,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div>
) : (
<div className="relative w-full min-h-[1.5rem]">
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
{/* View Layer with Inline Markdown */}
<div
onClick={(e) => {
if (!activeFocus && canFocus && textareaRef.current) {
@@ -1182,11 +1368,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
? 'text-lg sm:text-xl font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
? 'text-base font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
@@ -1196,104 +1382,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
</span>
{/* Inline Badge and 3-Dots directly behind the text */}
{block.type === 'todo' && (
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
{block.taskId && (
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(block.taskId as string);
}}
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
title="Erweiterte Aufgabe öffnen"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</button>
)}
{canEdit && (
<span className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
const targetDocId = docId || block.documentId || block.docId || '';
const thought = await api.createEnhancedTask(targetDocId, block.id, {
content: localContent || 'Aufgabe',
checked: Boolean(block.checked),
});
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: thought.id,
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
onOpenTask?.(thought.id);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
) : (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
if (block.taskId) {
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
}
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: '',
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<X className="w-3.5 h-3.5 text-status-warn" />
<span>Verknüpfung aufheben</span>
</button>
)}
</div>
)}
</span>
)}
</span>
)}
</div>
<textarea
@@ -1309,15 +1397,47 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onChange={handleContentChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onClick={() => {
if (slashMatchInfo) {
const ta = textareaRef.current;
if (ta) {
const cursor = ta.selectionStart;
const textBefore = ta.value.slice(0, cursor);
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
if (match) {
const slashStart = match.index! + match[1].length;
setSlashMatchInfo({ query: match[2], slashStart, cursor });
} else {
setSlashMatchInfo(null);
}
}
}
}}
onKeyUp={(e) => {
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key) && slashMatchInfo) {
const ta = textareaRef.current;
if (ta) {
const cursor = ta.selectionStart;
const textBefore = ta.value.slice(0, cursor);
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
if (match) {
const slashStart = match.index! + match[1].length;
setSlashMatchInfo({ query: match[2], slashStart, cursor });
} else {
setSlashMatchInfo(null);
}
}
}
}}
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
? 'text-lg sm:text-xl font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
? 'text-base font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
@@ -1326,6 +1446,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div>
)}
{/* If To-Do is an enhanced task, render badge stably outside text layer */}
{block.type === 'todo' && block.taskId && (
<div className="flex items-center gap-1 mt-0.5 select-none">
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(block.taskId as string);
}}
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
title="Erweiterte Aufgabe öffnen"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</button>
</div>
)}
{/* Conflict copy: text that lost against someone else's change */}
{block.conflictOf && (
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
@@ -1417,7 +1556,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Floating Slash Commands Menu */}
{slashSearch !== null && canEdit && (
<div className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1">
<div
ref={slashMenuRef}
className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1"
>
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
Befehl auswählen
</div>
@@ -1435,7 +1577,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}}
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
i === slashIndex
? 'bg-accent text-on-accent'
? 'bg-accent-dim text-accent font-semibold'
: 'text-main hover:bg-panel-elevated'
}`}
>
@@ -1456,29 +1598,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
</div>
{/* Branch into its own note (desktop; phones use /extract) */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
// Phones zoom by tapping the bullet; the button would only take width from the text there
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Someone else is editing this block */}
{lockedBy && (
<span
@@ -1490,7 +1609,145 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</span>
)}
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
{/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */}
<div
className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${
activeFocus
? 'opacity-100 pointer-events-auto'
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
}`}
>
{/* Convert back to bullet for headings, callouts, divider */}
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider'].includes(block.type) && (
<button
type="button"
onClick={() => {
refocusAfterTypeChangeRef.current = true;
flushDebounce(localContent, { type: 'bullet', checked: false });
}}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In normalen Text umwandeln"
>
<Type className="w-3.5 h-3.5" />
</button>
)}
{/* To-Do 3-dots Menu */}
{block.type === 'todo' && canEdit && (
<div className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
ref={todoMenuRef}
className="absolute right-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
const targetDocId = docId || block.documentId || block.docId || '';
const thought = await api.createEnhancedTask(targetDocId, block.id, {
content: localContent || 'Aufgabe',
checked: Boolean(block.checked),
});
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: thought.id,
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
onOpenTask?.(thought.id);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
) : (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
if (block.taskId) {
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
}
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: '',
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<X className="w-3.5 h-3.5 text-status-warn" />
<span>Verknüpfung aufheben</span>
</button>
)}
<button
type="button"
onClick={() => {
setShowTodoMenu(false);
refocusAfterTypeChangeRef.current = true;
flushDebounce(localContent, { type: 'bullet', checked: false, taskId: '' });
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer border-t border-border mt-0.5 pt-1.5"
>
<Type className="w-3.5 h-3.5 text-accent" />
<span>In normalen Text umwandeln</span>
</button>
</div>
)}
</div>
)}
{/* Branch into its own note */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button */}
{onZoomBlock && (
<button
type="button"
onClick={() => onZoomBlock(block.id)}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
)}
{/* Delete button */}
{canEdit && (
<button
type="button"
@@ -1503,16 +1760,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onDeleteBlockRef.current(block.id, false);
}
}}
className={
activeFocus
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-status-err rounded-btn transition-colors cursor-pointer"
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Link Insertion Modal */}
{showLinkModal && (
@@ -129,9 +129,9 @@ describe('slash command menu and outliner fixes', () => {
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
expect(queryByText('Befehl auswählen')).toBeNull();
fireEvent.change(ta, { target: { value: 'Text /co' } });
fireEvent.change(ta, { target: { value: 'Text /ca' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/code')).toBeTruthy();
expect(queryByText('/callout')).toBeTruthy();
// Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } });
@@ -375,3 +375,106 @@ describe('formatting shortcuts (Teil C)', () => {
expect(readerTa.value).toBe('text');
});
});
describe('outliner polish (Teil A)', () => {
it('opens slash menu at cursor position in the middle of text and inserts link at cursor', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: 'Vorher Nachher', level: 0 } as DocumentBlock;
const { container, queryByText, getByText, getByPlaceholderText, getByRole } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// Type "/link" in the middle of the text (selectionStart at 12, right after "/link")
fireEvent.change(ta, { target: { value: 'Vorher /link Nachher', selectionStart: 12, selectionEnd: 12 } });
// Menu should be open with /link, but without /divider or /h1
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/link')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
expect(queryByText('/h1')).toBeNull();
// Select /link by pressing Enter
fireEvent.keyDown(ta, { key: 'Enter' });
// Link modal opens
expect(queryByText('Web-Link')).toBeTruthy();
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/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
// Result has link inserted in the middle
expect(updates[updates.length - 1]?.content).toBe('Vorher [Doku](https://example.com/test) Nachher');
});
it('does not offer /divider at end of text, but offers it in empty block', () => {
const block = { id: 'b1', type: 'bullet', content: 'Ein Text', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// At end of non-empty text
fireEvent.change(ta, { target: { value: 'Ein Text /div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
// In an empty block
fireEvent.change(ta, { target: { value: '/div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeTruthy();
});
it('converts H1 block back to bullet via /text slash command', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'h1', content: 'Meine Überschrift', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'Meine Überschrift /text' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/text')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Enter' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('Meine Überschrift');
});
it('closes slash menu on mousedown outside', () => {
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: '/' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
// Click outside on document body
fireEvent.mouseDown(document.body);
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('converts focused divider to empty bullet on Backspace', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'd1', type: 'divider', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.keyDown(ta, { key: 'Backspace' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('');
});
});
+6 -4
View File
@@ -318,12 +318,14 @@ body {
pointer-events: none;
}
.count-badge-accent {
background-color: var(--accent-primary);
color: var(--text-on-accent);
background: var(--accent-dim);
color: var(--accent-primary);
border: 1px solid var(--accent-border);
}
.count-badge-muted {
background-color: var(--text-muted);
color: var(--bg-panel);
background-color: var(--bg-panel-elevated);
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.count-badge-corner {
position: absolute;