192 lines
7.8 KiB
Markdown
192 lines
7.8 KiB
Markdown
# Feature: Markdown-Editor Verbesserungen
|
||
|
||
## Motivation
|
||
|
||
Der Wiki-Editor in Ka-Note basiert auf **Tiptap v3** (ProseMirror). Für Nutzer die primär mit Tastatur und Maus arbeiten fehlten:
|
||
- Ein schneller Weg in den Edit-Modus (ohne Button-Klick)
|
||
- Kontextuelle Formatierungshilfe beim Schreiben (Slash-Menü)
|
||
- Schnell-Formatierung bei Textauswahl (Bubble Menu)
|
||
|
||
Referenz-UI: UpNote (Slash-Menü, mobiler Toolbar-Bereich).
|
||
|
||
---
|
||
|
||
## Umgesetzte Features
|
||
|
||
### 1. Doppelklick → Edit-Modus
|
||
|
||
**Datei:** `ka-note/client/src/routes/wiki/[id]/+page.svelte`
|
||
|
||
Im Read-Modus aktiviert ein Doppelklick auf den Inhaltsbereich oder den Seitentitel den Editor.
|
||
|
||
- Titel (`<span>`, Zeile ~118): `ondblclick={() => editing = true}`
|
||
- Content-Container (`<div>`, Zeile ~202): `ondblclick={() => editing = true}` + `cursor-text`
|
||
- Einfacher Klick auf `[[Wikilinks]]` → Navigation (unverändert)
|
||
- Doppelklick auf Linktext → öffnet Editor (kein Konflikt, da Browser-Doppelklick = Textauswahl, nicht Click-Event auf Anchor)
|
||
|
||
---
|
||
|
||
### 2. Slash-Command-Menü (`/`)
|
||
|
||
**Datei:** `ka-note/client/src/lib/editor/tiptapSlashCommand.ts`
|
||
|
||
Tippt man `/` am Zeilenanfang oder nach einem Leerzeichen, erscheint ein Kontext-Menü mit Block-Befehlen.
|
||
|
||
#### Verfügbare Befehle
|
||
|
||
| Icon | Label | Tiptap-Command |
|
||
|------|-------|----------------|
|
||
| `H1` | Heading 1 | `toggleHeading({ level: 1 })` |
|
||
| `H2` | Heading 2 | `toggleHeading({ level: 2 })` |
|
||
| `H3` | Heading 3 | `toggleHeading({ level: 3 })` |
|
||
| `•–` | Bullet List | `toggleBulletList()` |
|
||
| `1.` | Ordered List | `toggleOrderedList()` |
|
||
| `` `x` `` | Code (inline) | `toggleCode()` |
|
||
| ` ``` ` | Code Block | `toggleCodeBlock()` |
|
||
| `❝` | Blockquote | `toggleBlockquote()` |
|
||
| `⊞` | Table (3×3) | `insertTable({ rows: 3, cols: 3, withHeaderRow: true })` |
|
||
| `—` | Divider | `setHorizontalRule()` |
|
||
|
||
#### Verhalten
|
||
|
||
- Trigger: `/` nach Whitespace oder am Zeilenanfang
|
||
- Typing filtert die Liste (Label + Keywords, deutsch + englisch)
|
||
- Arrow Up/Down → navigieren, Enter → ausführen, Escape → schließen
|
||
- Mausklick auf Eintrag → ausführen
|
||
- Vor Ausführung: `/` + getippter Query-Text werden gelöscht (`deleteRange`)
|
||
- URLs wie `https://example.com` triggern das Menü **nicht** (`:` vor `/` bricht den Scan ab)
|
||
|
||
#### Architektur
|
||
|
||
Exakt das gleiche ProseMirror Plugin-Pattern wie `tiptapMention.ts` und `tiptapWikiLink.ts`:
|
||
- `Extension.create()` mit `addProseMirrorPlugins()`
|
||
- Rückwärts-Scan im `update(view)`-Hook
|
||
- DOM-Dropdown in `.ka-editor-wrapper` (renutzt `.mention-dropdown`/`.mention-item`/`.mention-item-active` aus `mention.css`)
|
||
- `pointerdown` + `preventDefault()` für Klick-Handling ohne Fokusverlust
|
||
|
||
---
|
||
|
||
### 3. Bubble Menu (Textauswahl-Toolbar)
|
||
|
||
**Extension:** `@tiptap/extension-bubble-menu` (Tiptap v3, floating-ui-basiert)
|
||
|
||
Bei Textauswahl erscheint eine Mini-Toolbar mit:
|
||
|
||
| Button | Funktion |
|
||
|--------|----------|
|
||
| **B** | Bold toggle |
|
||
| *I* | Italic toggle |
|
||
| H2 | Heading 2 toggle |
|
||
| ↗ | Link setzen/entfernen (`window.prompt`) |
|
||
|
||
- Buttons werden aktiv (blau) wenn der Cursor im entsprechenden Mark/Node sitzt
|
||
- Verschwindet automatisch wenn Auswahl aufgehoben wird
|
||
- Nicht aktiv bei Bild-Auswahl (`!e.isActive('image')`)
|
||
- `onpointerdown` + `preventDefault()` verhindert Fokusverlust im Editor
|
||
|
||
#### Tiptap v3 BubbleMenu API
|
||
|
||
v3 nutzt `@floating-ui/dom` für Positionierung (kein tippy.js mehr).
|
||
Visibility wird über `element.style.visibility` gesteuert (nicht `display`).
|
||
Der `element`-Prop erwartet einen DOM-Node — in Svelte via `bind:this={bubbleMenuEl}`.
|
||
|
||
---
|
||
|
||
## Neue Tiptap-Extensions (Table)
|
||
|
||
Für das Table-Slash-Command wurden hinzugefügt:
|
||
- `@tiptap/extension-table`
|
||
- `@tiptap/extension-table-row`
|
||
- `@tiptap/extension-table-cell`
|
||
- `@tiptap/extension-table-header`
|
||
|
||
Konfiguration: `Table.configure({ resizable: false })`
|
||
|
||
**Hinweis:** `tiptap-markdown` v0.9 serialisiert Tabellen als GFM (`| col | col |`). Beim ersten produktiven Einsatz verifizieren dass Round-Trip (Edit → Save → reOpen) korrekt funktioniert.
|
||
|
||
---
|
||
|
||
## Geänderte / neue Dateien
|
||
|
||
| Datei | Änderung |
|
||
|-------|----------|
|
||
| `client/src/routes/wiki/[id]/+page.svelte` | `ondblclick` auf Titel-Span + Content-Container |
|
||
| `client/src/lib/components/MarkdownEditor.svelte` | 7 neue Imports, `bubbleMenuEl`-Variable, 6 neue Extensions, Bubble-Menu-Template |
|
||
| `client/src/lib/editor/tiptapSlashCommand.ts` | Neu — Slash-Command Extension |
|
||
| `client/src/lib/editor/editor.css` | Neu — Slash-Icon + Bubble-Menu-Styles |
|
||
|
||
---
|
||
|
||
## Nicht umgesetzt (bewusste Entscheidung)
|
||
|
||
- **Feste Toolbar**: Passt nicht zum minimalistischen UI-Stil; Bubble Menu ist ausreichend für Desktop
|
||
- **Mobile Keyboard-Toolbar**: Separates Thema — erfordert `visualViewport`-Handling für iOS; bei Bedarf als eigenes Feature
|
||
- **Link-inline-Dialog**: `window.prompt` als MVP; langfristig ein kleines Svelte-Popover
|
||
- **Tabellen-Resize**: `resizable: false` — die Resize-Handles brauchen zusätzliches CSS-Styling
|
||
|
||
---
|
||
|
||
## Bekannte Einschränkungen
|
||
|
||
- `/code` (inline) — nach dem Löschen des Slash-Texts gibt es keine Auswahl; das Code-Mark wird als "stored mark" für den nächsten Tipp-Vorgang aktiviert (Tiptap-Standardverhalten)
|
||
- Bubble Menu Link-Button nutzt `window.prompt` — blockiert kurz den Browser
|
||
|
||
---
|
||
|
||
## Read-Mode: RenderedMarkdown-Pipeline
|
||
|
||
### Komponenten
|
||
|
||
| Komponente | Datei | Zweck |
|
||
|-----------|-------|-------|
|
||
| `RenderedMarkdown` | `components/RenderedMarkdown.svelte` | Generisches Render-Primitiv (marked + DOMPurify). Props: `text`, `historyEntryId?`, `topicId?`, `class?` |
|
||
| `HistoryEntryText` | `components/HistoryEntryText.svelte` | Wrapper für HistoryEntry-Text. Erzwingt `entry: EntryLike` als Pflicht-Prop, leitet `id`/`topicId` an `RenderedMarkdown` weiter |
|
||
| `refClick` | `actions/refClick.ts` | Svelte Action — Event-Delegation für @mentions, Assignments, Task-Chips, Wiki-Links |
|
||
|
||
### Wann `HistoryEntryText` vs. `RenderedMarkdown`
|
||
|
||
**`HistoryEntryText`** — immer wenn der Text einem HistoryEntry gehört (Chip-Klick soll Text aktualisieren können):
|
||
```svelte
|
||
<HistoryEntryText {entry} />
|
||
<HistoryEntryText {entry} textOverride={body} class="text-sm" />
|
||
```
|
||
|
||
**`RenderedMarkdown`** — für echte read-only-Kontexte ohne HistoryEntry-Bezug:
|
||
```svelte
|
||
<RenderedMarkdown text={page.body} /> <!-- Wiki-Seite -->
|
||
<RenderedMarkdown text={content} /> <!-- EditableMarkdown-Preview -->
|
||
```
|
||
|
||
`RenderedMarkdown` direkt mit expliziten IDs — wenn kein vollständiges Entry-Objekt vorhanden (projizierte Typen mit abweichenden Feldnamen):
|
||
```svelte
|
||
<RenderedMarkdown text={entry.text} historyEntryId={entry.historyEntryId} topicId={entry.topicId} />
|
||
```
|
||
|
||
### `EntryLike`-Interface
|
||
|
||
`HistoryEntryText` akzeptiert ein strukturelles Minimum — kompatibel mit vollständigen `HistoryEntry`-Objekten und schmalen Projektionen:
|
||
```typescript
|
||
interface EntryLike { id: string; topicId: string; text: string; }
|
||
```
|
||
TypeScript erzwingt alle drei Felder. Vergessene IDs → Compile-Fehler (nicht silent failure wie vorher).
|
||
|
||
### `refClick`-Action: Param-Übergabe
|
||
|
||
Die Action empfängt `historyEntryId`/`topicId` explizit über `use:refClick`. `RenderedMarkdown` setzt dies intern — Aufrufer müssen die Action nie direkt verwenden.
|
||
|
||
Bei Task-Chip-Klick dispatcht `refClick` ein `ka-open-task-create`-Event mit `historyEntryId` im Detail. `ContextPage` fängt es und löst daraus `contextId` auf:
|
||
```
|
||
historyEntryId → db.historyEntries.get() → entry.topicId → db.topics.get() → topic.contextId
|
||
```
|
||
|
||
### Vollständige Render-Chain (Task-Flow)
|
||
|
||
```
|
||
[] Aufgabe → renderMarkdown.ts (marked extension)
|
||
→ <span data-task-new data-task-line="base64">[ ]</span>
|
||
→ RenderedMarkdown (use:refClick={{ historyEntryId, topicId }})
|
||
→ Klick → handleTaskNewClick(historyEntryId, topicId)
|
||
→ ka-open-task-create event (bubbles up to ContextPage)
|
||
→ createTask() + updateHistoryEntry([] → [T:id])
|
||
```
|