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:
commit
7a90e6dae3
8 files changed
+680
-212
No files matched your search
+12
-1
@@ -7,14 +7,22 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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}`.
|
- **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).
|
- 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
|
### 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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- 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: 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: 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).
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const desktopAnchor = anchor
|
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;
|
: undefined;
|
||||||
|
|
||||||
return (
|
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
|
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 ${
|
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
||||||
anchor
|
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'
|
: '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 {
|
import {
|
||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
ArrowUp,
|
ArrowDown,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
@@ -73,6 +73,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
});
|
});
|
||||||
const isListening = voice.state === 'listening';
|
const isListening = voice.state === 'listening';
|
||||||
const isTranscribing = voice.state === 'transcribing';
|
const isTranscribing = voice.state === 'transcribing';
|
||||||
|
const [isOverMaxHeight, setIsOverMaxHeight] = useState(false);
|
||||||
|
|
||||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||||
const targetEl = el || inputRef.current;
|
const targetEl = el || inputRef.current;
|
||||||
@@ -82,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const scrollH = targetEl.scrollHeight;
|
const scrollH = targetEl.scrollHeight;
|
||||||
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
||||||
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
||||||
|
setIsOverMaxHeight(scrollH > 140);
|
||||||
} else {
|
} else {
|
||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
|
setIsOverMaxHeight(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,16 +270,18 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
: isTranscribing
|
: isTranscribing
|
||||||
? 'Wird erkannt…'
|
? 'Wird erkannt…'
|
||||||
: hideTargetPicker || fixedTarget === 'inbox'
|
: hideTargetPicker || fixedTarget === 'inbox'
|
||||||
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
? 'Quick Dump erfassen…'
|
||||||
: target === 'task'
|
: target === 'task'
|
||||||
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Aufgabe erfassen…'
|
||||||
: target === 'list_item'
|
: target === 'list_item'
|
||||||
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
|
? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
|
||||||
: target === 'document'
|
: target === 'document'
|
||||||
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Notiz erfassen…'
|
||||||
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
|
: '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' }}
|
style={{ minHeight: '22px', maxHeight: '140px' }}
|
||||||
autoComplete="off"
|
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">
|
<span className="inline">
|
||||||
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||||
? 'Quick Dump'
|
? 'Quick Dump'
|
||||||
|
|||||||
@@ -725,7 +725,73 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</div>
|
</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">
|
<div className="hidden sm:flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('discard')}
|
onClick={() => handleAction('discard')}
|
||||||
|
|||||||
@@ -160,4 +160,26 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
expect(api.triageAction).not.toHaveBeenCalled();
|
expect(api.triageAction).not.toHaveBeenCalled();
|
||||||
expect(onDeckChange).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
|
Type,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { LinkModal } from './LinkModal';
|
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: '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: '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: '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: '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: '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" /> },
|
{ 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;
|
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
|
// 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 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)
|
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
|
||||||
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
|
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
|
||||||
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
|
// 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);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
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);
|
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
|
// 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]);
|
}, [adjustHeight, block.type]);
|
||||||
|
|
||||||
const filteredSlashCommands = useMemo(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (slashSearch === null) return [];
|
if (!slashMatchInfo) return [];
|
||||||
const q = slashSearch.toLowerCase();
|
const q = slashMatchInfo.query.toLowerCase();
|
||||||
const cmdQuery = q.startsWith('code ') ? 'code' : q;
|
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() === '';
|
const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart);
|
||||||
return SLASH_COMMANDS.filter(
|
const textAfterCursor = localContent.slice(slashMatchInfo.cursor);
|
||||||
(c) =>
|
const restOfBlock = (textBeforeSlash + textAfterCursor).trim();
|
||||||
(!c.media || emptyBullet) &&
|
const isRestEmpty = restOfBlock === '';
|
||||||
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
|
const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === '';
|
||||||
// The bullet's text becomes the title of the new note
|
|
||||||
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
|
return SLASH_COMMANDS.filter((c) => {
|
||||||
(c.label.toLowerCase().includes(cmdQuery) ||
|
const matchesQuery =
|
||||||
c.desc.toLowerCase().includes(cmdQuery) ||
|
c.label.toLowerCase().includes(cmdQuery) ||
|
||||||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
|
c.desc.toLowerCase().includes(cmdQuery) ||
|
||||||
);
|
(c.aliases || []).some((a) => a.includes(cmdQuery));
|
||||||
}, [slashSearch, localContent, onExtractBlock]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (slashSearch === null) return;
|
if (slashSearch === null) return;
|
||||||
@@ -386,15 +468,37 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}, [slashIndex, slashSearch]);
|
}, [slashIndex, slashSearch]);
|
||||||
|
|
||||||
const applySlashCommand = (cmd: SlashCommand) => {
|
const applySlashCommand = (cmd: SlashCommand) => {
|
||||||
setSlashSearch(null);
|
if (blurSlashTimerRef.current !== null) {
|
||||||
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
|
window.clearTimeout(blurSlashTimerRef.current);
|
||||||
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
|
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') {
|
if (cmd.type === 'link') {
|
||||||
|
linkBeforeRef.current = before;
|
||||||
|
linkAfterRef.current = after;
|
||||||
setLocalContent(clean);
|
setLocalContent(clean);
|
||||||
localContentRef.current = clean;
|
localContentRef.current = clean;
|
||||||
setShowLinkModal(true);
|
setShowLinkModal(true);
|
||||||
return;
|
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') {
|
if (cmd.type === 'extract') {
|
||||||
setLocalContent(clean);
|
setLocalContent(clean);
|
||||||
localContentRef.current = clean;
|
localContentRef.current = clean;
|
||||||
@@ -402,31 +506,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onExtractBlock?.(block.id);
|
onExtractBlock?.(block.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.media) {
|
if (cmd.media) {
|
||||||
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
|
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
|
||||||
setLocalContent('');
|
setLocalContent('');
|
||||||
localContentRef.current = '';
|
localContentRef.current = '';
|
||||||
flushDebounce('');
|
flushDebounce('');
|
||||||
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
|
setMediaPicker(cmd.type as 'image' | 'audio');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i);
|
||||||
const langOverride =
|
const langOverride =
|
||||||
cmd.type === 'code' && codeLangMatch
|
cmd.type === 'code' && codeLangMatch
|
||||||
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
|
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
setLocalContent(clean);
|
setLocalContent(clean);
|
||||||
localContentRef.current = clean;
|
localContentRef.current = clean;
|
||||||
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
|
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
|
||||||
flushDebounce(clean, {
|
flushDebounce(clean, {
|
||||||
type: cmd.type as BlockType, // media commands returned above
|
type: cmd.type as BlockType,
|
||||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||||
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
||||||
|
...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInsertLink = (linkMarkdown: string) => {
|
const handleInsertLink = (linkMarkdown: string) => {
|
||||||
setShowLinkModal(false);
|
setShowLinkModal(false);
|
||||||
if (localContent.trim() === '') {
|
const b = linkBeforeRef.current ?? '';
|
||||||
|
const a = linkAfterRef.current ?? '';
|
||||||
|
if (b.trim() === '' && a.trim() === '') {
|
||||||
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
|
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
|
||||||
if (webMatch) {
|
if (webMatch) {
|
||||||
const bareUrl = webMatch[1];
|
const bareUrl = webMatch[1];
|
||||||
@@ -436,10 +547,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
|
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
|
||||||
const before = localContent.slice(0, cursor);
|
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
|
||||||
const after = localContent.slice(cursor);
|
const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim();
|
||||||
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
|
|
||||||
setLocalContent(newContent);
|
setLocalContent(newContent);
|
||||||
localContentRef.current = newContent;
|
localContentRef.current = newContent;
|
||||||
flushDebounce(newContent);
|
flushDebounce(newContent);
|
||||||
@@ -512,6 +622,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
val = val.replace(/^\[[xX]\]\s/, '');
|
val = val.replace(/^\[[xX]\]\s/, '');
|
||||||
typeOverride = 'todo';
|
typeOverride = 'todo';
|
||||||
checkedOverride = true;
|
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') {
|
} else if (val.startsWith('# ') && block.type !== 'h1') {
|
||||||
val = val.slice(2);
|
val = val.slice(2);
|
||||||
typeOverride = 'h1';
|
typeOverride = 'h1';
|
||||||
@@ -521,18 +635,32 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
||||||
val = val.slice(4);
|
val = val.slice(4);
|
||||||
typeOverride = 'h3';
|
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
|
// Check for slash command invocation at cursor position
|
||||||
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
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) {
|
if (slashMatch) {
|
||||||
setSlashSearch(slashMatch[2]);
|
const slashStart = slashMatch.index! + slashMatch[1].length;
|
||||||
|
setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor });
|
||||||
setSlashIndex(0);
|
setSlashIndex(0);
|
||||||
} else {
|
} else {
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalContent(val);
|
setLocalContent(val);
|
||||||
@@ -548,6 +676,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
type: typeOverride,
|
type: typeOverride,
|
||||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||||
checked: checkedOverride ?? latestBlockRef.current.checked,
|
checked: checkedOverride ?? latestBlockRef.current.checked,
|
||||||
|
...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -562,6 +691,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
|
if (blurSlashTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(blurSlashTimerRef.current);
|
||||||
|
blurSlashTimerRef.current = null;
|
||||||
|
}
|
||||||
isFocusedRef.current = true;
|
isFocusedRef.current = true;
|
||||||
setIsLocallyFocused(true);
|
setIsLocallyFocused(true);
|
||||||
onFocusBlock?.(block.id);
|
onFocusBlock?.(block.id);
|
||||||
@@ -572,6 +705,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
setIsLocallyFocused(false);
|
setIsLocallyFocused(false);
|
||||||
onBlurBlock?.(block.id);
|
onBlurBlock?.(block.id);
|
||||||
|
if (blurSlashTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(blurSlashTimerRef.current);
|
||||||
|
}
|
||||||
|
blurSlashTimerRef.current = window.setTimeout(() => {
|
||||||
|
setSlashMatchInfo(null);
|
||||||
|
}, 150);
|
||||||
if (isPastingRef.current) {
|
if (isPastingRef.current) {
|
||||||
isPastingRef.current = false;
|
isPastingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -756,7 +895,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (slashSearch !== null) {
|
if (slashSearch !== null) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (filteredSlashCommands.length > 0) {
|
if (filteredSlashCommands.length > 0) {
|
||||||
@@ -796,7 +935,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
|
|
||||||
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
|
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
|
||||||
if (fenceMatch) {
|
if (fenceMatch) {
|
||||||
@@ -856,7 +995,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (e.key === 'Backspace' && localContent === '') {
|
if (e.key === 'Backspace' && localContent === '') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
debounceTimerRef.current = null;
|
||||||
@@ -882,7 +1021,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -932,6 +1071,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
|
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
|
isDragTarget
|
||||||
? 'border-t-2 border-accent bg-accent/10'
|
? 'border-t-2 border-accent bg-accent/10'
|
||||||
: isTargeted
|
: isTargeted
|
||||||
@@ -1071,14 +1216,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<DictationBlock
|
<DictationBlock
|
||||||
onDone={(text) => {
|
onDone={(text) => {
|
||||||
setMediaPicker(null);
|
setMediaPicker(null);
|
||||||
setLocalContent(text);
|
const b = dictationBeforeRef.current ?? '';
|
||||||
localContentRef.current = text;
|
const a = dictationAfterRef.current ?? '';
|
||||||
flushDebounce(text);
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
const ta = textareaRef.current;
|
const ta = textareaRef.current;
|
||||||
if (!ta) return;
|
if (!ta) return;
|
||||||
ta.focus();
|
ta.focus();
|
||||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
const pos = (b + spaceBefore + text).length;
|
||||||
|
ta.setSelectionRange(pos, pos);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
@@ -1128,8 +1279,43 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : block.type === 'divider' ? (
|
) : block.type === 'divider' ? (
|
||||||
<div className="py-2">
|
<div
|
||||||
<hr className="border-t border-border" />
|
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>
|
</div>
|
||||||
) : block.type === 'code' ? (
|
) : block.type === 'code' ? (
|
||||||
<CodeBlockEditor
|
<CodeBlockEditor
|
||||||
@@ -1169,7 +1355,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative w-full min-h-[1.5rem]">
|
<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
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!activeFocus && canFocus && textareaRef.current) {
|
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'
|
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
|
||||||
} ${
|
} ${
|
||||||
block.type === 'h1'
|
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'
|
: block.type === 'h2'
|
||||||
? 'text-base font-semibold text-main'
|
? 'text-lg sm:text-xl font-semibold text-main'
|
||||||
: block.type === 'h3'
|
: block.type === 'h3'
|
||||||
? 'text-sm font-semibold text-main'
|
? 'text-base font-semibold text-main'
|
||||||
: block.type === 'todo' && block.checked
|
: block.type === 'todo' && block.checked
|
||||||
? 'text-sm text-faint line-through'
|
? 'text-sm text-faint line-through'
|
||||||
: 'text-sm text-main'
|
: 'text-sm text-main'
|
||||||
@@ -1196,104 +1382,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1309,15 +1397,47 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
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] ${
|
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'
|
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
||||||
} ${
|
} ${
|
||||||
block.type === 'h1'
|
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'
|
: block.type === 'h2'
|
||||||
? 'text-base font-semibold text-main'
|
? 'text-lg sm:text-xl font-semibold text-main'
|
||||||
: block.type === 'h3'
|
: block.type === 'h3'
|
||||||
? 'text-sm font-semibold text-main'
|
? 'text-base font-semibold text-main'
|
||||||
: block.type === 'todo' && block.checked
|
: block.type === 'todo' && block.checked
|
||||||
? 'text-sm text-faint line-through'
|
? 'text-sm text-faint line-through'
|
||||||
: 'text-sm text-main'
|
: 'text-sm text-main'
|
||||||
@@ -1326,6 +1446,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</div>
|
</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 */}
|
{/* Conflict copy: text that lost against someone else's change */}
|
||||||
{block.conflictOf && (
|
{block.conflictOf && (
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
|
<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 */}
|
{/* Floating Slash Commands Menu */}
|
||||||
{slashSearch !== null && canEdit && (
|
{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">
|
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
|
||||||
Befehl auswählen
|
Befehl auswählen
|
||||||
</div>
|
</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 ${
|
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
||||||
i === slashIndex
|
i === slashIndex
|
||||||
? 'bg-accent text-on-accent'
|
? 'bg-accent-dim text-accent font-semibold'
|
||||||
: 'text-main hover:bg-panel-elevated'
|
: 'text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1456,29 +1598,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Someone else is editing this block */}
|
||||||
{lockedBy && (
|
{lockedBy && (
|
||||||
<span
|
<span
|
||||||
@@ -1490,29 +1609,164 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</span>
|
</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) */}
|
||||||
{canEdit && (
|
<div
|
||||||
<button
|
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 ${
|
||||||
type="button"
|
activeFocus
|
||||||
onClick={() => {
|
? 'opacity-100 pointer-events-auto'
|
||||||
if (hasChildren) {
|
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
|
||||||
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
|
}`}
|
||||||
onDeleteBlockRef.current(block.id, true);
|
>
|
||||||
|
{/* 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"
|
||||||
|
onClick={() => {
|
||||||
|
if (hasChildren) {
|
||||||
|
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
|
||||||
|
onDeleteBlockRef.current(block.id, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onDeleteBlockRef.current(block.id, false);
|
||||||
}
|
}
|
||||||
} else {
|
}}
|
||||||
onDeleteBlockRef.current(block.id, 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-status-err rounded-btn transition-colors cursor-pointer"
|
||||||
}
|
title="Block löschen"
|
||||||
}}
|
>
|
||||||
className={
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
activeFocus
|
</button>
|
||||||
? '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'
|
</div>
|
||||||
}
|
|
||||||
title="Block löschen"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Link Insertion Modal */}
|
{/* Link Insertion Modal */}
|
||||||
{showLinkModal && (
|
{showLinkModal && (
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ describe('slash command menu and outliner fixes', () => {
|
|||||||
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
|
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
|
||||||
expect(queryByText('Befehl auswählen')).toBeNull();
|
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('Befehl auswählen')).toBeTruthy();
|
||||||
expect(queryByText('/code')).toBeTruthy();
|
expect(queryByText('/callout')).toBeTruthy();
|
||||||
|
|
||||||
// Removing the slash closes the menu
|
// Removing the slash closes the menu
|
||||||
fireEvent.change(ta, { target: { value: 'Text ' } });
|
fireEvent.change(ta, { target: { value: 'Text ' } });
|
||||||
@@ -375,3 +375,106 @@ describe('formatting shortcuts (Teil C)', () => {
|
|||||||
expect(readerTa.value).toBe('text');
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -318,12 +318,14 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.count-badge-accent {
|
.count-badge-accent {
|
||||||
background-color: var(--accent-primary);
|
background: var(--accent-dim);
|
||||||
color: var(--text-on-accent);
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
}
|
}
|
||||||
.count-badge-muted {
|
.count-badge-muted {
|
||||||
background-color: var(--text-muted);
|
background-color: var(--bg-panel-elevated);
|
||||||
color: var(--bg-panel);
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.count-badge-corner {
|
.count-badge-corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in new issue
Block a user