Ka-Note/docs/feature-mdeditor.md

7.8 KiB
Raw Permalink Blame History

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):

<HistoryEntryText {entry} />
<HistoryEntryText {entry} textOverride={body} class="text-sm" />

RenderedMarkdown — für echte read-only-Kontexte ohne HistoryEntry-Bezug:

<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):

<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:

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])