diff --git a/Doku/README.md b/Doku/README.md index 66cbbe1..ef02845 100644 --- a/Doku/README.md +++ b/Doku/README.md @@ -17,9 +17,10 @@ Doku/ ├── modules/ │ ├── triage.md # Modul 1: Triage & Quick-Dump (State Machine & Audio) │ ├── tasks.md # Modul 2: Aufgaben & Kanban-System (Alarme & Scheduler) -│ ├── workspace.md # Modul 3: Workspace Outliner-Engine, Wiki-Links & Disk-Sync +│ ├── workspace.md # Modul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage │ └── lists.md # Modul 4: Geteilte Listen (Live-Kollaboration & D&D) ├── sharing-and-permissions.md # Synchrones Teilen, Rollen, Isolation & Mount-Entkopplung +├── reverse-proxy.md # Nginx, Traefik, Caddy, Cloudflare Konfiguration & SSE ├── theming.md # Single-File CSS Theme-Engine & Design-System └── flaws.md # Technische Schwachstellen, Risiken & zukünftige Optimierungen ``` @@ -32,21 +33,23 @@ Doku/ 3. **[REST-API Referenz](api/rest.md)** Spezifikation aller Endpunkte für Authentifizierung, Gedanken, Aufgaben, Dokumente, Listen, Teams und Webhooks. 4. **[WebSocket & Echtzeit-Synchronisation](api/websocket.md)** - Gorilla WebSocket Hub, Rooms, Presence-Tracking, Document Locking Mechanismen und Polling-Fallback. + Gorilla WebSocket Hub, Rooms, Presence-Tracking, Yjs CRDT Kollaboration und SSE Fallback. 5. **[Kernmodul: Triage & Quick-Dump](modules/triage.md)** Instant-Capture-Pipeline, Finite State Machine, Gestensteuerung, Spracheingabe und Webhook-Ingestion. 6. **[Kernmodul: Aufgaben & Kanban-System](modules/tasks.md)** Multi-Listen-Konzept, 3-Spalten Kanban-Board, Subtasks, Fälligkeiten und Multi-Channel Alarm-Ticker (SMTP, Discord, Webhooks). 7. **[Kernmodul: Workspace & Outliner-Engine](modules/workspace.md)** - Logseq-inspirierte hierarchische Block-Engine, Zoom-Hoisting, Wiki-Links, Disk-Mirroring (`.md`) und Globales To-Do Rollup. + Logseq-inspirierte hierarchische Block-Engine, Zoom-Hoisting, Wiki-Links, Pure-Database Storage und Globales To-Do Rollup. 8. **[Kernmodul: Geteilte Listen & Kollaboration](modules/lists.md)** Listen-Typen, Mengen, Kategorien, Live-Checkoff und Drag & Drop Sortierung. 9. **[Synchrones Teilen & Berechtigungskonzept](sharing-and-permissions.md)** Rollenmodell (Admin/Member), System-Isolations-Modi (`strict_private`, `teams_only`, `open`), entkoppelte Ordner-Mounts und Notizen-Freigaben. -10. **[Single-File Theming & Design-System](theming.md)** +10. **[Reverse-Proxy- & Ingress-Handbuch](reverse-proxy.md)** + Produktionsfertige Konfigurationen für Nginx, Traefik, Caddy, Cloudflare und Apache mit WebSocket-Upgrades und SSE-Streaming. +11. **[Single-File Theming & Design-System](theming.md)** 1-Datei = 1-Theme Philosophie, die 6 CSS-Variablen-Blöcke, Semantische CSS-Klassen und Geometrie-Steuerung. -11. **[Technische Schwachstellen & Optimierungspotenziale (Flaws)](flaws.md)** - Kritische Analyse von Concurrency-Grenzen, Dual-Storage-Risiken, Undo/Redo, Volltextsuche und Skalierungs-Engpässen. +12. **[Technische Schwachstellen & Optimierungspotenziale (Flaws)](flaws.md)** + Kritische Analyse von Concurrency-Grenzen, Volltextsuche, Viewport-Anpassung und Skalierungs-Engpässen. --- @@ -68,12 +71,11 @@ graph TB ChiRouter["Chi Router (REST API)"] WSHub["Gorilla WebSocket Hub & Lock Manager"] AlarmTicker["Alarm Scheduler (Background Ticker)"] - DiskSync["Disk Mirror Engine (/data/workspace)"] + StorageEngine["Storage Layer (Domain & Repositories)"] end subgraph Storage ["Storage Layer"] SQLite[("SQLite WAL Database (/data/mindsift.db)")] - DiskFiles[("Physische Markdown Files (/data/workspace)")] end subgraph External ["Externe Schnittstellen"] @@ -86,9 +88,8 @@ graph TB WSClient -->|WSS Realtime| Traefik Traefik --> ChiRouter Traefik --> WSHub - ChiRouter --> SQLite - ChiRouter --> DiskSync - DiskSync --> DiskFiles + ChiRouter --> StorageEngine + StorageEngine --> SQLite AlarmTicker --> Discord AlarmTicker --> SMTPServer HomeAssistant -->|POST /api/v1/inbox| Traefik diff --git a/Doku/api/websocket.md b/Doku/api/websocket.md index c1f4fb1..ba172ba 100644 --- a/Doku/api/websocket.md +++ b/Doku/api/websocket.md @@ -64,50 +64,107 @@ Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen: --- -## 🔒 4. Dokument-Sperrsystem (Document Locking) +--- -Um Schreibkonflikte bei Notizen im Workspace zu vermeiden, verwendet MindSift ein **serverseitiges Lease-Locking mit Heartbeat**: +## ⚡ 4. CRDT-Echtzeitprotokoll (Yjs Collaborative Editing) + +Seit Version 2.1 unterstützt MindSift **echte, konfliktfreie Mehrbenutzer-Kollaboration** für Notizen via Yjs CRDT. Clients tauschen binäre Delta-Updates und Live-Awareness-Informationen über dedizierte WebSocket-Nachrichten aus: + +### CRDT-Nachrichtentypen +| Event | Richtung | Zweck / Payload | +|---|---|---| +| `CRDT_JOIN` | Client ➔ Server | Tritt dem Dokumentenraum (`doc:{docId}`) bei. | +| `CRDT_INIT` | Server ➔ Client | Sendet den aktuellen Basiszustand (`stateBlob`) und offene Delta-Updates (`updates[]`) an den Client. | +| `CRDT_UPDATE` | Bidirektional | Überträgt inkrementelle binäre Yjs-Updates (Base64-kodiert). Server speichert in `document_crdt_updates` und verteilt an alle anderen Raumnutzer. | +| `CRDT_COMPACT` | Client ➔ Server | Komprimiert mehrere Updates zu einem konsolidierten Snapshot in `document_crdt_state`. | +| `CRDT_AWARENESS` | Bidirektional | Überträgt Live-Präsenz (Fokus auf `blockId`, Benutzername, Farbe). | +| `CRDT_AWARENESS_LEAVE` | Bidirektional | Signalisiert, dass ein Nutzer den Fokus verloren hat oder das Dokument verlassen hat. | +| `CRDT_LEAVE` | Client ➔ Server | Verlässt den Dokumentenraum explizit. | ```mermaid sequenceDiagram autonumber actor UserA as Benutzer A - participant WS as WebSocket Hub + participant WS as WebSocket Hub & SQLite actor UserB as Benutzer B - UserA->>WS: LOCK_DOC (docId: "doc_123") - WS-->>UserA: Lock bestätigt (30s TTL) - WS->>UserB: DOC_LOCKED (docId: "doc_123", lockedBy: "Benutzer A") - Note over UserB: Editor gesperrt (Schreibschutz aktiv) + UserA->>WS: CRDT_JOIN (docId: "doc_123") + WS-->>UserA: CRDT_INIT (stateBlob, updates) + UserB->>WS: CRDT_JOIN (docId: "doc_123") + WS-->>UserB: CRDT_INIT (stateBlob, updates) - loop Alle 15 Sekunden - UserA->>WS: HEARTBEAT_DOC (docId: "doc_123") - WS-->>UserA: TTL um 30s verlängert - end + UserA->>WS: CRDT_UPDATE (docId, updateBlob) + WS->>WS: Append in document_crdt_updates + WS->>UserB: CRDT_UPDATE (docId, updateBlob) + Note over UserB: Yjs wendet Delta atomar an (0 Konflikte) - alt Reguläres Schließen - UserA->>WS: UNLOCK_DOC (docId: "doc_123") - WS->>UserB: DOC_UNLOCKED (docId: "doc_123") - Note over UserB: Editor wieder freigegeben - else Verbindungsabbruch / Tab geschlossen - Note over WS: Heartbeat bleibt aus (TTL läuft ab) - WS->>UserB: DOC_UNLOCKED (Automatisch nach Ablauf) - end + UserA->>WS: CRDT_AWARENESS (docId, focusedBlockId) + WS->>UserB: CRDT_AWARENESS (docId, user, focusedBlockId) + Note over UserB: Avatar-Badge erscheint am Block ``` -### Locking-Events -- **`LOCK_DOC` (Client ➔ Server):** Fordert eine 30-Sekunden-Sperre für ein Dokument an. -- **`HEARTBEAT_DOC` (Client ➔ Server):** Verlängert die Sperre alle 15 Sekunden, solange der Benutzer das Dokument geöffnet hat. -- **`UNLOCK_DOC` (Client ➔ Server):** Gibt die Sperre beim Verlassen des Dokuments frei. -- **`DOC_LOCKED` (Server ➔ Clients):** Informiert andere Benutzer, dass das Dokument schreibgeschützt ist. -- **`DOC_UNLOCKED` (Server ➔ Clients):** Hebt den Schreibschutz auf. - --- -## 🔄 5. Fallback-Strategie & Reconnect +## 🔒 5. Abwärtskompatibles Document-Lease-Locking -In strikten Firmennetzwerken oder bei Proxy-Problemen (z.B. fehlende `Upgrade`-Header bei HTTP/1.1) kann eine WebSocket-Verbindung fehlschlagen: +Vor Version 2.1 nutzte MindSift ein serverseitiges Lease-Locking mit 30-Sekunden-Heartbeats. Durch die CRDT-Engine wurde die blockierende Bearbeitungssperre (`isLockedByOther`) abgelöst: +- **Aktuelles Verhalten:** Mehrere Benutzer mit Bearbeitungsrechten können zeitgleich dasselbe Dokument bearbeiten. +- **Presence & Locks:** Die Events `LOCK_DOC` und `HEARTBEAT_DOC` bleiben abwärtskompatibel erhalten und dienen nun der reinen Präsenzanzeige (z.B. gelber Statusindikator „wird gerade von X angesehen"). +- **Schreibschutz:** Ein echter Schreibschutz (`isReadOnly`) greift ausschließlich noch dann, wenn der Nutzer nur über Leserechte (`permission === 'read'`) für die Notiz oder den Ordner verfügt. + +--- + +## 🔄 5. Fallback-Strategie & Proxy-Resilienz (Dual-Transport Architektur) + +In restriktiven Firmennetzwerken, hinter Unternehmens-Proxies (z.B. Zscaler, BlueCoat) oder fehlerhaft konfigurierten Reverse-Proxies wird der `Upgrade: websocket`-Header häufig verworfen (`NS_ERROR_CONNECTION_REFUSED`, Code 1006 / 1002 / 1008). + +MindSift löst dieses Problem durch eine **vollwertige Dual-Transport-Architektur** ([FLAW-03](../flaws.md)): + +``` + ┌───────────────────────────────┐ + │ MindSift Frontend │ + └───────────────┬───────────────┘ + │ + ┌─────────────────────┴─────────────────────┐ + ▼ ▼ + Standard: WebSockets Fallback: SSE + REST + (wss://.../api/v1/ws) (https://.../api/v1/events) + │ │ + │ [Proxy Upgrade Block (Code 1006/Timeout)]│ + │ ────────────────────────────────────────> │ + ▼ ▼ + ┌────────────────────────┐ ┌────────────────────────┐ + │ Gorilla WS Conn │ │ Server-Sent Events │ + │ Bidirektionaler │ │ (text/event-stream) │ + │ TCP-WebSocket │ │ Standard HTTPS Port │ + └────────────┬───────────┘ └────────────┬───────────┘ + │ │ + └─────────────────────┬─────────────────────┘ + ▼ + ┌─────────────────────────────┐ + │ Realtime Hub │ + │ (Multiplexing WS & SSE) │ + └─────────────────────────────┘ +``` + +### 1. Automatischer SSE-Fallback (`GET /api/v1/events`) +- Scheitert der WebSocket-Handshake oder blockiert ein Proxy das Upgrade, wechselt MindSift innerhalb von 3,5 Sekunden automatisch auf einen **Server-Sent Events (SSE) Stream**. +- Da SSE ein standardmäßiger HTTP/1.1- bzw. HTTP/2-GET-Request über Standard-Port 443/80 ist (`Content-Type: text/event-stream`), wird er von jedem Proxy und jeder Firewall ungehindert durchgeleitet. +- Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` verhindern Pufferung im Proxy; periodische Ping-Kommentare (`: ping\n\n`) alle 15 Sekunden halten Timeouts fern. +- Der Client bleibt dadurch **vollständig echtzeitfähig** (`Live (SSE)`). + +### 2. REST-Endpunkte für Upstream-Aktionen im SSE-Modus +Da SSE unidirektional (Server ➔ Client) ist, stellt MindSift für Schreibvorgänge im SSE-Modus REST-Äquivalente bereit: +- `PUT /api/v1/documents/:id/crdt`: Unterstützt sowohl volles `state` (Compaction) als auch inkrementelles `update` (Delta). Der Hub persistiert das Update und broadcastet es sofort an alle anderen WS- und SSE-Clients im Dokumentenraum. +- `POST /api/v1/documents/:id/awareness`: Überträgt Cursor- und Blockfokus-Präsenzen. +- `DELETE /api/v1/documents/:id/awareness`: Informiert Peers beim Verlassen des Dokuments. +- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients, Dokument- oder Listenräumen dynamisch beizutreten (`join`) oder sie zu verlassen (`leave`). + +### 3. Verbindungs-Indikator +- **Grün pulsierend:** Live verbunden über WebSockets (`Live (WebSocket)`). +- **Türkis pulsierend:** Live verbunden über Server-Sent Events (`Live (Server-Sent Events / Proxy-Stream)`). +- **Gelb/Amber:** Temporäre Unterbrechung mit automatischem HTTP-Polling-Schutz. + +Ausführliche Konfigurationsbeispiele für Nginx, Traefik, Caddy, Cloudflare und Apache finden sich im separaten Handbuch: +👉 **[Reverse-Proxy-Konfigurationshandbuch](../reverse-proxy.md)** -1. **Exponentielles Backoff:** Das Frontend ([`frontend/src/lib/api.ts`](../../frontend/src/lib/api.ts)) versucht automatisch die Wiederverbindung nach 1s, 2s, 4s, 8s, bis maximal 30s. -2. **HTTP-Polling Fallback:** Geteilte Listen und Notizen aktivieren im Hintergrund ein periodisches Polling (5s Intervall), falls der WebSocket-Status nicht `CONNECTED` ist. -3. **Status-Indikator:** Ein dezenter grüner bzw. gelber Indikator oben rechts im Header zeigt dem Benutzer jederzeit den aktuellen Live-Verbindungsstatus an. diff --git a/Doku/architecture.md b/Doku/architecture.md index aa1606c..2dd336f 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -138,5 +138,5 @@ MindSift wird als schlanker, autarker Docker-Container ausgeliefert: 3. **Stage 3 (Production Runtime):** `alpine:3.20` mit `ca-certificates` und `tzdata`. Das finale Image ist ca. 45 MB groß und benötigt keine externen Datenbankserver oder Abhängigkeiten. Alle persistenten Daten befinden sich in einem einzigen Verzeichnis `/data`: -- `/data/mindsift.db` (SQLite Hauptdatenbank) -- `/data/workspace/` (Physische Markdown-Dateien der Workspace-Notizen) +- `/data/mindsift.db` (SQLite Hauptdatenbank inklusive WAL-Journal und Shared-Memory-Dateien) +- Notizen und Workspace-Blöcke werden zu 100% in der SQLite-Datenbank persistiert (keine losen Markdown-Dateien auf der Festplatte). diff --git a/Doku/database.md b/Doku/database.md index fb49e55..3b73140 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -208,7 +208,7 @@ CREATE TABLE IF NOT EXISTS task_list_shares ( ### 2.4 Workspace: Dokumente, Blöcke & Geteilte Ordner #### `documents` & `document_blocks` (Database-First Outliner) -Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `document_blocks` verwaltet wird. Zusätzlich spiegelt MindSift die Daten synchron als physische `.md`-Datei auf Festplatte. +Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `document_blocks` verwaltet wird. MindSift speichert alle Notizen zu 100% in SQLite (keine physischen `.md`-Dateien auf Festplatte). ```sql CREATE TABLE IF NOT EXISTS documents ( @@ -216,7 +216,7 @@ CREATE TABLE IF NOT EXISTS documents ( user_id TEXT NOT NULL, -- Eigentümer title TEXT NOT NULL, -- Dokumenttitel icon TEXT NOT NULL DEFAULT '📄', -- Emoji-Icon - content TEXT NOT NULL DEFAULT '', -- Markdown-Volltext (Spiegelung) + content TEXT NOT NULL DEFAULT '', -- Plaintext / Text-Repräsentation (Cache) blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy JSON-Cache tags TEXT NOT NULL DEFAULT '[]', -- JSON Array von Tags folder_path TEXT NOT NULL DEFAULT '', -- Relativer Ordnerpfad des Eigentümers diff --git a/Doku/flaws.md b/Doku/flaws.md index c0739a7..13b33a8 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -8,8 +8,8 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle | ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb | |---|---|---|---|---| -| **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Kritisch** | Zeilenverlust bei gleichzeitigen Bearbeitungen oder Netzschwankungen | -| **FLAW-02** | Storage | Dual-Storage Drift (SQLite `document_blocks` vs. Dateisystem `.md`) | **Hoch** | Externe Änderungen an `.md` werden nicht automatisch in SQLite gespiegelt | +| **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Gelöst (v2.1)** | Behoben durch Yjs CRDT-Engine & blockbasierte kollaborative Bearbeitung | +| **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) | | **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Mittel** | Verzögerte Synchronisierung (1–3s Polling) in Firmennetzwerken | | **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) | | **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | @@ -23,35 +23,50 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle ## 🔍 Detaillierte Analyse & Maßnahmen -### FLAW-01: Kein echtes CRDT/OT – Lock-Heartbeat & Last-Write-Wins -- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `frontend/src/lib/components/Outliner.svelte` -- **Ursache:** MindSift nutzt zur Vermeidung von Konflikten eine kooperative Bearbeitungssperre (*Document Lease Lock*, 30 Sekunden TTL mit Heartbeat). Wenn zwei Nutzer dasselbe Dokument geöffnet haben, blockiert der aktive Schreiber das Bearbeiten für andere. Verliert ein Nutzer jedoch kurzzeitig die WebSocket-Verbindung oder tippt während des Übergangs zum Polling-Fallback, werden Änderungen per REST via Last-Write-Wins (`PUT /api/v1/documents/:id`) gespeichert. -- **Folgen:** Wie in früheren Versionen beobachtet, können bei parallelem Schreiben Zeilen überschrieben werden oder Strukturhierarchien (Parent-IDs) verloren gehen. -- **Empfohlene Lösung:** - 1. Langfristige Migration auf ein echtes blockweises CRDT-Framework (wie [Yjs](https://yjs.dev/) oder [Automerge](https://automerge.org/)). - 2. Kurzfristig: Optimistisches Block-Locking (Sperre nur auf Block-Ebene `block_id` statt des gesamten Dokuments) und atomare Versionsprüfung (*Optimistic Concurrency Control* mit `version_seq`). +### FLAW-01: [GELÖST / IMPLEMENTIERT] Yjs Outliner CRDT & Multi-User Kollaboration +- **Status:** **Gelöst via Yjs CRDT Engine (Lösung i)** +- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `internal/storage/sqlite.go`, `frontend/src/lib/crdt.ts`, `frontend/src/features/workspace/WorkspaceView.tsx` +- **Ursache (Historisch):** MindSift nutzte ein kooperatives Dokumenten-Lease-Locking mit Last-Write-Wins. Bei parallelem Zugriff oder Netztrennung konnten Änderungen überschrieben werden. +- **Implementierte Lösung (Yjs CRDT):** + 1. **Block-Level Yjs CRDT:** Implementierung eines vollwertigen CRDT-Modells mit `Y.Doc`. Die Blockreihenfolge wird über ein `Y.Array` (Block-IDs) konfliktfrei mit dem YATA-Algorithmus aufgelöst. Inhalte und Metadaten (Text, Level, Checked, Collapsed) sind in `Y.Map` gekapselt. + 2. **Wegfall von Ganzdokument-Sperren:** Keine blockierenden Sperren mehr (`isLockedByOther` entfernt). Mehrere Nutzer können zeitgleich am selben Dokument schreiben. + 3. **Dual-Persistence:** CRDT-Zustände und Delta-Updates werden binär/Base64 in SQLite (`document_crdt_state`, `document_crdt_updates`) persistiert. Zur Aufrechterhaltung relationaler Indizes (Global To-Dos, Tag-Indizierung, Suche) werden Blöcke parallel in `document_blocks` materialisiert. + 4. **Live-Awareness & Presence:** Block-Fokus und Cursor-Zustand werden via `CRDT_AWARENESS` übertragen. Nutzer sehen ein Live-Badge im Header (`Live (N)`) sowie farbige Kollaborator-Pillen an aktiven Blöcken. + 5. **REST- & Proxy-Sicherheit:** Ergänzend zu WebSockets stehen Endpunkte `GET /api/v1/documents/:id/crdt` und `PUT /api/v1/documents/:id/crdt` bereit. --- -### FLAW-02: Dual-Storage Drift (SQLite vs. Dateisystem `.md`) -- **Betroffene Komponenten:** [`database.md`](./database.md), `internal/storage/sqlite.go`, `internal/storage/markdown_sync.go` -- **Ursache:** MindSift pflegt Notizen primär in SQLite (`documents`, `document_blocks`), exportiert bzw. spiegelt sie jedoch als reine `.md`-Dateien auf das Dateisystem (`data/notes/`), um Exportierbarkeit und lokale Lesbarkeit zu gewährleisten. -- **Problem:** Es existiert kein systemweiter File-Watcher ([fsnotify](https://github.com/fsnotify/fsnotify)). Bearbeitet der Nutzer eine Notizdatei direkt auf dem Host (z. B. via Obsidian, VS Code, Git-Sync oder SSH), bekommt die SQLite-Datenbank dies nicht mit. Beim nächsten Web-Edit überschreibt SQLite die Datei auf der Festplatte. -- **Empfohlene Lösung:** - 1. Integrieren eines Background-File-Watchers mit `fsnotify` für das Datenverzeichnis. - 2. Implementieren einer Hash-Prüfung (`sha256` der Datei vs. DB-Metadaten) vor dem Speichern. Bei Abweichung: Nutzerkonflikt-Dialog anzeigen. +### FLAW-02: [OBSOLET / GELÖST] Dual-Storage Drift (SQLite vs. Dateisystem) +- **Status:** **Entkräftet & Behoben (Pure Database Architecture)** +- **Klarstellung:** In früheren Entwürfen wurde angedacht, Notizen zusätzlich als Markdown-Dateien auf dem Dateisystem zu spiegeln. MindSift speichert Notizen und Blöcke jedoch **zu 100% relational in SQLite** (`documents` und `document_blocks`). +- **Verifikation:** + - Es existieren **keine** Markdown-Dateien auf der Festplatte. + - Das Go-Backend schreibt keine Dateien in ein Notizverzeichnis. + - Unnötige Verzeichniserstellungen (`docDir`/`workspace`) wurden aus `internal/storage/sqlite.go` vollständig entfernt. +- **Ergebnis:** Es gibt keinen Dual-Storage und somit keinen File-Drift oder externe Dateikonflikte. Notizen verhalten sich vollständig transaktional und konsistent innerhalb der SQLite-Datenbank. --- -### FLAW-03: Proxy-Inkompatibilität bei WebSocket-Upgrades -- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), `frontend/src/lib/stores/websocket.js` -- **Ursache:** In restriktiven Firmennetzwerken oder über bestimmte Reverse-Proxies (Corporate SSL-Termination, Firewalls) schlägt der HTTP-Upgrade-Header `Upgrade: websocket` fehl (`NS_ERROR_CONNECTION_REFUSED` oder `400 Bad Request`). -- **Folgen:** Der Client schaltet in den HTTP-Polling-Fallback um (alle 2 Sekunden). Dadurch: - - Werden Sperren (*Document Locks*) und Online-Statusanzeigen verzögert übertragen. - - Entsteht unnötige Serverlast durch kontinuierliche HTTP-Requests. -- **Empfohlene Lösung:** - 1. Sicherstellen von standardmäßigen Nginx/Traefik-Snippets in der Dokumentation (`proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";`). - 2. Optionale Implementierung von Server-Sent Events (SSE) als unidirektionale Echtzeit-Alternative für Lesevorgänge bei blockiertem WebSocket. +### FLAW-03: [GELÖST / IMPLEMENTIERT] Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback +- **Status:** **Gelöst & Vollständig Implementiert (Dual-Transport SSE Fallback & Proxy-Resilienz)** +- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`reverse-proxy.md`](./reverse-proxy.md), [`internal/ws/hub.go`](../internal/ws/hub.go), [`internal/api/server.go`](../internal/api/server.go), [`frontend/src/lib/api.ts`](../frontend/src/lib/api.ts) +- **Problemursache:** Corporate Proxies (z.B. Zscaler, BlueCoat, strikte Firewalls) oder unvollständig konfigurierte Reverse-Proxies verwerfen den HTTP-Upgrade-Header `Upgrade: websocket` (`NS_ERROR_CONNECTION_REFUSED`, Status 1006 / 1002). Früher fiel der Client dabei auf 2-Sekunden-Polling zurück, was zu Verzögerungen und Last führte. +- **Implementierte Lösung:** + 1. **Unidirektionaler Server-Sent Events (SSE) Fallback:** + - Neuer Streaming-Endpunkt `GET /api/v1/events` im Go-Backend mit `Content-Type: text/event-stream`, `X-Accel-Buffering: no` und 15s Heartbeat-Pings (`: ping\n\n`). + - Standard HTTPS Ports (443/80), wodurch kein Proxy den Datenstrom blockieren kann. + 2. **Hub-Multiplexing:** + - `ws.Hub` verwaltet WebSocket- und SSE-Clients parallel in gemeinsamen Räumen (`rooms`, `userRooms`, `allClients`). + - Sämtliche Events (`BroadcastToAll`, `BroadcastToUser`, `BroadcastRoom`) werden transparent an beide Transportarten verteilt. + 3. **REST-Parität für Outbound CRDT & Awareness:** + - `PUT /api/v1/documents/:id/crdt`: Akzeptiert Delta-Updates (`update`) und broadcastet sie an alle Peers. + - `POST /api/v1/documents/:id/awareness` und `DELETE /api/v1/documents/:id/awareness`: Übertragen Blockfokus und Cursor ohne WebSocket. + - `POST /api/v1/events/rooms`: Erlaubt SSE-Clients dynamisches Room-Joining. + 4. **Frontend Realtime Transport Manager (`frontend/src/lib/api.ts`):** + - Automatischer 3,5s-Watchdog: Schlägt das WebSocket-Upgrade fehl oder wird Code 1006/1002 gemeldet, wechselt MindSift nahtlos auf `EventSource('/api/v1/events')`. + - Grüner/türkiser Statusindikator im Header zeigt `Live (WebSocket)` bzw. `Live (Server-Sent Events / Proxy-Stream)`. + 5. **Reverse-Proxy-Handbuch:** + - Vollständige Produktions-Konfigurationen für Nginx, Traefik, Caddy, Cloudflare und Apache unter [`reverse-proxy.md`](./reverse-proxy.md). --- diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index e433f0a..be55f6a 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -1,6 +1,6 @@ -# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Disk-Sync +# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage -Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Markdown-Wiki** und einem **globalen To-Do Rollup Dashboard**. +Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Wiki** und einem **globalen To-Do Rollup Dashboard**. --- @@ -46,30 +46,40 @@ Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben, --- -## 💾 3. Dual-Storage: Database-First & Disk-Mirroring +--- -MindSift löst das Dilemma zwischen schneller relationaler Abfrage und portabler Dateispeicherung durch ein **synchrones Dual-Storage-Modell**: +## ⚡ 3. Yjs CRDT Real-Time Engine & Dual-Persistence +Ab MindSift v2.1 setzt der Workspace auf ein **konfliktfreies Replicated Data Type (CRDT) Modell** auf Basis von [Yjs](https://yjs.dev/): + +### Funktionsweise +1. **CRDT-Struktur im Client:** + - Ein `Y.Doc` pro geöffnetem Dokument. + - `Y.Array` (`blocks`): Führt die Block-IDs in korrekter Reihenfolge. Der YATA-Algorithmus garantiert, dass simultanes Einfügen, Verschieben oder Löschen von Blöcken durch mehrere Nutzer deterministisch und konfliktfrei konvergiert. + - `Y.Map` (`block:`): Speichert Inhalt (`content`), Einrückung (`level`), Erledigungsstatus (`checked`) und Faltungsstatus (`collapsed`). +2. **Kollaborative Bearbeitung ohne Sperren:** + - Nutzer können simultan dieselbe Notiz bearbeiten. + - Fokus- und Cursor-Positionen werden via `CRDT_AWARENESS` übertragen und als farbige Benutzer-Pillen direkt am jeweiligen Block visualisiert. + +### Dual-Persistence in SQLite ``` -Client (Block-Editor) - │ PATCH /api/v1/documents/:id/blocks - ▼ - Backend Engine - ┌──────────────────────────────────────────────┐ - │ 1. Transaktion in SQLite (document_blocks) │ - │ -> Sort-Order, Content, Level, Checked │ - │ 2. Volltext-Generierung (Markdown-String) │ - │ 3. Schreiben auf Disk (/data/workspace/...) │ - └──────────────────────────────────────────────┘ +Client (Y.Doc Mutation) + │ + ├──▶ WebSocket (CRDT_UPDATE Base64 Delta) + │ │ + │ ▼ + │ SQLite: document_crdt_updates / document_crdt_state + │ (Mathematisch exakte CRDT-Konvergenz) + │ + └──▶ Periodischer Snapshot / Flush + │ + ▼ + SQLite: document_blocks + (Relationale Indizes für Global To-Dos, Tag-Explorer & FTS) ``` -1. **Präzision & Geschwindigkeit (SQLite):** - - Jeder Block besitzt eine eindeutige ID (`blk_...`) und eine Gleitkomma-Reihenfolge (`sort_order REAL`). - - Schnelle Status-Updates (z.B. Checkbox abhaken) schreiben nur genau eine Zeile in die Datenbank. -2. **Datensouveränität & Portabilität (Disk-Mirror):** - - Zeitgleich spiegelt der Server das Dokument als Standard-`.md`-Datei unter `/data/workspace///.md`. - - Einrückungen werden nach dem 2-Leerzeichen-Standard (` - [ ] `) formatiert. - - Administratoren können das Verzeichnis jederzeit mit externen Werkzeugen (Obsidian, Logseq, Git, rsync) sichern. +1. **Mathematische CRDT-Garantie:** Binäre Yjs-Updates werden in `document_crdt_updates` und periodisch als Snapshot in `document_crdt_state` gesichert. Auch nach einem Server-Neustart oder Offline-Phasen konvergieren alle Clients verlustfrei. +2. **Relationale Query-Fähigkeit:** Um schnelle SQL-Abfragen für das globale To-Do Rollup Dashboard und Hashtags zu ermöglichen, werden die Blöcke parallel in `document_blocks` materialisiert. Es existieren **keine Markdown-Dateien auf der Festplatte**. --- @@ -79,4 +89,4 @@ Client (Block-Editor) - Scannt in Echtzeit alle unvollendeten `- [ ] ` Checkboxen über alle Ordner und Notizen hinweg. - Übersichtliche Gruppierung nach **Ordner ➔ Dokument ➔ Aufgabe**. - **Ordner-Filter:** Klick auf einen Ordner isoliert nur dessen Aufgaben für fokussiertes Arbeiten. -- **2-Wege-Live-Sync:** Wird eine Checkbox im Rollup abgehakt, wird der Block in SQLite sofort auf `checked = 1` aktualisiert und die physische `.md`-Datei synchronisiert. +- **2-Wege-Live-Sync:** Wird eine Checkbox im Rollup abgehakt, wird der Block in SQLite sofort auf `checked = 1` aktualisiert und via WebSocket an alle aktiven Clients gebroadcastet. diff --git a/Doku/reverse-proxy.md b/Doku/reverse-proxy.md new file mode 100644 index 0000000..1cb3e52 --- /dev/null +++ b/Doku/reverse-proxy.md @@ -0,0 +1,229 @@ +# 🛡️ Reverse-Proxy & WebServer-Konfigurationshandbuch + +Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und Ingress-Controllern für MindSift. MindSift nutzt eine **hybride Realtime-Architektur** bestehend aus bidirektionalen **WebSockets** und einem automatischen **Server-Sent Events (SSE) Fallback**, um selbst in restriktivsten Firmennetzwerken, hinter strikten Firewalls oder bei Deep-Packet-Inspection unterbrechungsfreie Live-Synchronisation zu gewährleisten. + +--- + +## ⚡ Wichtige Anforderungen auf einen Blick + +| Protokoll | Standard-Port | Benötigte Header & Einstellungen | Zweck | +| :--- | :--- | :--- | :--- | +| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`
`Connection "Upgrade"`
`proxy_read_timeout 3600s` | Bidirektionale Yjs-CRDT-Synchronisation & Awareness | +| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`
`proxy_buffering off`
`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade | +| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Anhänge & CRDT-Zustandsblöcke | + +--- + +## 1. Nginx + +Eine typische `nginx.conf` bzw. Site-Konfiguration unter `/etc/nginx/sites-available/mindsift`: + +```nginx +# Map für sauberes Connection-Handling bei WebSocket-Upgrades +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name mindsift.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name mindsift.example.com; + + ssl_certificate /etc/letsencrypt/live/mindsift.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/mindsift.example.com/privkey.pem; + + # Maximale Payload-Größe für Backups und große CRDT-Zustände + client_max_body_size 64M; + + # MindSift Frontend & REST API + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 1. WebSocket Endpunkte (/api/v1/ws) + location /api/v1/ws { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + + # Upgrade-Header zwingend erforderlich + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Verhindert vorzeitigen Verbindungsabbruch durch Proxy-Inaktivitäts-Timeout + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # 2. Server-Sent Events Fallback (/api/v1/events) + location /api/v1/events { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + + # Pufferung zwingend deaktivieren für sofortiges Event-Streaming + proxy_buffering off; + proxy_cache off; + proxy_set_header X-Accel-Buffering no; + + proxy_set_header Connection ''; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 86400s; + } +} +``` + +--- + +## 2. Traefik (Docker Compose / Ingress) + +Bei Verwendung von Traefik Version 2/3 müssen keine expliziten WebSocket-Upgrades deklariert werden, da Traefik diese transparent durchleitet. Für Server-Sent Events und Timeouts empfehlen sich Middleware-Definitionen: + +```yaml +version: '3.8' + +services: + mindsift: + image: ghcr.io/sandrodach/mindsift:latest + restart: unless-stopped + environment: + - PORT=8080 + - DB_PATH=/app/data/mindsift.db + volumes: + - ./data:/app/data + labels: + - "traefik.enable=true" + - "traefik.http.routers.mindsift.rule=Host(`mindsift.example.com`)" + - "traefik.http.routers.mindsift.entrypoints=websecure" + - "traefik.http.routers.mindsift.tls.certresolver=letsencrypt" + - "traefik.http.services.mindsift.loadbalancer.server.port=8080" + + # Verhindert Pufferung und hält persistente SSE-Streams am Leben + - "traefik.http.middlewares.mindsift-buffering.buffering.maxRequestBodyBytes=67108864" + - "traefik.http.routers.mindsift.middlewares=mindsift-buffering" +``` + +--- + +## 3. Caddy Server (Caddyfile) + +Caddy unterstützt WebSockets und HTTP/2 Server-Sent Events von Haus aus vollständig ("out-of-the-box"): + +```caddyfile +mindsift.example.com { + encode gzip zstd + + # Notizen, CRDT & Event-Stream ohne Pufferung weiterleiten + reverse_proxy 127.0.0.1:8080 { + # Streaming für SSE optimieren + flush_interval -1 + } +} +``` + +--- + +## 4. Cloudflare Proxy (Orange Cloud) + +Wird MindSift hinter dem Cloudflare CDN betrieben: +1. **WebSockets aktivieren:** + - Navigiere in das Cloudflare Dashboard: **Network** > **WebSockets** > Schalter auf **ON** stellen. +2. **Cloudflare Rocket Loader / Auto Minify:** + - Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe die Yjs-CRDT-Initialisierung stören können. +3. **Cloudflare WAF / Bot Fight Mode:** + - Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden. +4. **SSE-Unterstützung:** + - Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients. + +--- + +## 5. Apache 2.4 (`mod_proxy_wstunnel`) + +Unter Debian/Ubuntu: +```bash +sudo a2enmod proxy proxy_http proxy_wstunnel headers +sudo systemctl restart apache2 +``` + +VirtualHost-Konfiguration (`/etc/apache2/sites-available/mindsift.conf`): + +```apache + + ServerName mindsift.example.com + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/mindsift.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/mindsift.example.com/privkey.pem + + # 1. WebSocket Tunneling + RewriteEngine On + RewriteCond %{HTTP:Upgrade} =websocket [NC] + RewriteRule /(.*) ws://127.0.0.1:8080/$1 [P,L] + RewriteCond %{HTTP:Upgrade} !=websocket [NC] + RewriteRule /(.*) http://127.0.0.1:8080/$1 [P,L] + + # 2. SSE Response Flushing + + ProxyPass "http://127.0.0.1:8080/api/v1/events" flushpackets=on + ProxyPassReverse "http://127.0.0.1:8080/api/v1/events" + + + ProxyPass / http://127.0.0.1:8080/ + ProxyPassReverse / http://127.0.0.1:8080/ + +``` + +--- + +## 🔍 Diagnose & Verifikation + +Mit den folgenden Befehlen lässt sich die Erreichbarkeit und Streaming-Funktion prüfen: + +### 1. Server-Sent Events Test (Standard-HTTPS) +```bash +curl -N -H "Accept: text/event-stream" -H "Authorization: Bearer " https://mindsift.example.com/api/v1/events +``` +*Erwartete Ausgabe:* +```text +: connected (transport: sse, id: sse_...) +data: {"event":"SSE_CONNECTED","payload":{"sseId":"sse_...","transport":"sse"},"timestamp":"..."} +: ping +``` + +### 2. WebSocket Handshake Test +```bash +curl -i -N \ + -H "Connection: Upgrade" \ + -H "Upgrade: websocket" \ + -H "Host: mindsift.example.com" \ + -H "Origin: https://mindsift.example.com" \ + -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \ + -H "Sec-WebSocket-Version: 13" \ + https://mindsift.example.com/api/v1/ws?token= +``` +*Erwartete Antwort:* `101 Switching Protocols` + +--- + +## 🧭 Verwandte Dokumente +- 🔌 [WebSocket & Live-Protokoll](api/websocket.md) +- 🏗️ [Gesamtarchitektur](architecture.md) +- 🚨 [Bekannte Schwachstellen & Flaws](flaws.md) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2201d64..11ddca9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,8 @@ "lucide-react": "^0.468.0", "prismjs": "^1.30.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^18.3.12", @@ -1840,6 +1841,16 @@ "node": ">=0.12.0" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -1882,6 +1893,27 @@ "node": ">=6" } }, + "node_modules/lib0": { + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz", + "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lilconfig": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", @@ -2756,6 +2788,23 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yjs": { + "version": "13.6.33", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz", + "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } } } } diff --git a/frontend/package.json b/frontend/package.json index ee43213..a4ff258 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,8 @@ "lucide-react": "^0.468.0", "prismjs": "^1.30.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "yjs": "^13.6.33" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 2fd00e0..8fc62f0 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -10,7 +10,7 @@ import { Hash, } from 'lucide-react'; import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; -import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; +import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { useMobileActions } from '../../context/MobileActionContext'; @@ -86,6 +86,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [isSubmittingItem, setIsSubmittingItem] = useState(false); const [wsConnected, setWsConnected] = useState(false); + const [realtimeTransport, setRealtimeTransport] = useState('none'); const [showNewListModal, setShowNewListModal] = useState(false); const [newListTitle, setNewListTitle] = useState(''); const [newListIcon, setNewListIcon] = useState('🛒'); @@ -105,6 +106,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState(null); const wsRef = useRef(null); + const realtimeConnRef = useRef(null); const loadLists = async () => { try { @@ -147,83 +149,79 @@ export const SharedListsView: React.FC = ({ onRefreshGloba useEffect(() => { if (!activeList?.id) return; - if (wsRef.current) { - wsRef.current.close(); + if (realtimeConnRef.current) { + try { + realtimeConnRef.current.close(); + } catch {} + realtimeConnRef.current = null; } - let ws: WebSocket | null = null; - try { - ws = new WebSocket(buildWebSocketURL(activeList.id)); - wsRef.current = ws; - - ws.onopen = () => { - setWsConnected(true); - }; - - ws.onclose = () => { - setWsConnected(false); - }; - - ws.onmessage = (evt) => { - try { - const msg: WSMessage = JSON.parse(evt.data); - const msgListId = msg.listId || (msg as any).list_id; - if (msg.event === 'ITEM_CREATED' && msgListId) { - const newItem: ListItem = msg.payload; - if (!newItem || !newItem.id) return; - setLists((prev) => - prev.map((l) => { - if (l.id !== msgListId) return l; - if (l.items.some((i) => i.id === newItem.id)) return l; - return { ...l, items: deduplicateItems([newItem, ...l.items]) }; - }) - ); - } else if (msg.event === 'ITEM_TOGGLED' && msgListId) { - const updatedItem: ListItem = msg.payload; - setLists((prev) => - prev.map((l) => { - if (l.id !== msgListId) return l; - return { - ...l, - items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)), - }; - }) - ); - } else if (msg.event === 'ITEM_DELETED' && msgListId) { - const deletedId = msg.payload?.itemId; - setLists((prev) => - prev.map((l) => { - if (l.id !== msgListId) return l; - return { ...l, items: l.items.filter((i) => i.id !== deletedId) }; - }) - ); - } else if (msg.event === 'LIST_CLEARED' && msgListId) { - setLists((prev) => - prev.map((l) => { - if (l.id !== msgListId) return l; - return { ...l, items: l.items.filter((i) => !i.isCompleted) }; - }) - ); - } else if ( - msg.event === 'LIST_CREATED' || - msg.event === 'LIST_DELETED' || - msg.event === 'LIST_SHARED' || - msg.event === 'LIST_UNSHARED' || - msg.event === 'ITEM_REORDERED' || - msg.event === 'LIST_UPDATED' - ) { - loadLists(); - } - } catch { - // ignore malformed frames + const conn = createRealtimeConnection({ + listId: activeList.id, + onStatusChange: (status) => { + setWsConnected(status.connected); + setRealtimeTransport(status.transport); + }, + onMessage: (msg: WSMessage) => { + const msgListId = msg.listId || (msg as any).list_id; + if (msg.event === 'ITEM_CREATED' && msgListId) { + const newItem: ListItem = msg.payload; + if (!newItem || !newItem.id) return; + setLists((prev) => + prev.map((l) => { + if (l.id !== msgListId) return l; + if (l.items.some((i) => i.id === newItem.id)) return l; + return { ...l, items: deduplicateItems([newItem, ...l.items]) }; + }) + ); + } else if (msg.event === 'ITEM_TOGGLED' && msgListId) { + const updatedItem: ListItem = msg.payload; + setLists((prev) => + prev.map((l) => { + if (l.id !== msgListId) return l; + return { + ...l, + items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)), + }; + }) + ); + } else if (msg.event === 'ITEM_DELETED' && msgListId) { + const deletedId = msg.payload?.itemId; + setLists((prev) => + prev.map((l) => { + if (l.id !== msgListId) return l; + return { ...l, items: l.items.filter((i) => i.id !== deletedId) }; + }) + ); + } else if (msg.event === 'LIST_CLEARED' && msgListId) { + setLists((prev) => + prev.map((l) => { + if (l.id !== msgListId) return l; + return { ...l, items: l.items.filter((i) => !i.isCompleted) }; + }) + ); + } else if ( + msg.event === 'LIST_CREATED' || + msg.event === 'LIST_DELETED' || + msg.event === 'LIST_SHARED' || + msg.event === 'LIST_UNSHARED' || + msg.event === 'ITEM_REORDERED' || + msg.event === 'LIST_UPDATED' + ) { + loadLists(); } - }; - } catch { - setWsConnected(false); - } + }, + }); + + realtimeConnRef.current = conn; return () => { - if (ws) ws.close(); + if (realtimeConnRef.current) { + try { + realtimeConnRef.current.close(); + } catch {} + realtimeConnRef.current = null; + } }; }, [activeList?.id]); @@ -821,12 +819,20 @@ export const SharedListsView: React.FC = ({ onRefreshGloba )}
@@ -927,12 +933,20 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Live Sync Status */}
diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index b8973fc..4b46cf6 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -50,11 +50,12 @@ import { Radio, } from 'lucide-react'; import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; -import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, buildWebSocketURL } from '../../lib/api'; +import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, buildWebSocketURL, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { useMobileActions } from '../../context/MobileActionContext'; +import { DocumentCRDTSession, CollaboratorAwareness, getCollaboratorColor } from '../../lib/crdt'; interface WorkspaceViewProps { currentUser?: User | null; @@ -129,11 +130,13 @@ export const WorkspaceView: React.FC = ({ const [showIconPicker, setShowIconPicker] = useState(false); const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false); - // Dedicated WebSocket connection & status for live indicator + // Dedicated WebSocket/SSE connection & status for live indicator const wsRef = useRef(null); + const realtimeConnRef = useRef(null); const wsReconnectTimerRef = useRef(null); const wsReconnectDelayRef = useRef(2000); const [wsConnected, setWsConnected] = useState(false); + const [realtimeTransport, setRealtimeTransport] = useState('none'); const [wsDisconnectReason, setWsDisconnectReason] = useState(null); // Folder state (synchronized with server) @@ -182,6 +185,12 @@ export const WorkspaceView: React.FC = ({ setActiveCursorOffset(offset); }; + const autoResizeTextarea = (el: HTMLTextAreaElement | null) => { + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }; + // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(null); const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null); @@ -216,7 +225,11 @@ export const WorkspaceView: React.FC = ({ const [showFolderShareModal, setShowFolderShareModal] = useState(false); const [folderShares, setFolderShares] = useState([]); - // --- DOCUMENT LOCKING STATE --- + // --- DOCUMENT CRDT & COLLABORATION STATE --- + const crdtSessionRef = useRef(null); + const [activePeers, setActivePeers] = useState([]); + + // --- DOCUMENT LOCKING STATE (FALLBACK) --- const [activeLock, setActiveLock] = useState(null); const [holdsLock, setHoldsLock] = useState(false); const holdsLockRef = useRef(false); @@ -228,8 +241,9 @@ export const WorkspaceView: React.FC = ({ const blockInputRefs = useRef>({}); - const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id); - const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther; + // With CRDT, multiple users can write simultaneously without lockouts + const isLockedByOther = false; + const isReadOnly = selectedDoc?.permission === 'read'; // Track server-synced blocks & metadata per document, plus local unsaved block deltas const syncedDocMetaRef = useRef>(new Map()); @@ -418,12 +432,46 @@ export const WorkspaceView: React.FC = ({ return false; }; - const handleBlockFocus = () => { + const broadcastAwareness = (blockId: string | null) => { + if (!selectedDoc) return; + const payload = { + userId: currentUser?.id, + userName: currentUser?.displayName || currentUser?.username || 'Kollege', + activeBlockId: blockId, + color: getCollaboratorColor(currentUser?.id || ''), + lastActive: Date.now(), + }; + if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { + realtimeConnRef.current.send({ + event: 'CRDT_AWARENESS', + docId: selectedDoc.id, + payload, + }); + } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + try { + wsRef.current.send( + JSON.stringify({ + event: 'CRDT_AWARENESS', + docId: selectedDoc.id, + payload, + }) + ); + } catch {} + } else { + api.sendDocumentAwareness(selectedDoc.id, payload).catch(() => {}); + } + }; + + const handleBlockFocus = (blockIdOrEvent?: string | React.FocusEvent) => { + const blockId = typeof blockIdOrEvent === 'string' ? blockIdOrEvent : undefined; isEditorFocusedRef.current = true; if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); idleUnlockTimerRef.current = null; } + if (blockId) { + broadcastAwareness(blockId); + } if (!isReadOnly) { ensureLock(); } @@ -431,6 +479,7 @@ export const WorkspaceView: React.FC = ({ const handleBlockBlur = () => { isEditorFocusedRef.current = false; + broadcastAwareness(null); if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } @@ -611,6 +660,11 @@ export const WorkspaceView: React.FC = ({ } }); + // Sync into active CRDT session + if (crdtSessionRef.current && crdtSessionRef.current.docId === updatedDoc.id) { + crdtSessionRef.current.syncFullBlocks(updatedDoc.blocks || []); + } + // 1. Update local UI state immediately for responsive, instant typing setSelectedDoc(updatedDoc); setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d))); @@ -732,16 +786,87 @@ export const WorkspaceView: React.FC = ({ const currentUserIdRef = useRef(null); currentUserIdRef.current = currentUser?.id || null; - // Handle document switch and lock status checks + // Handle document switch, CRDT session lifecycle, and lock status checks useEffect(() => { const prevId = currentDocIdRef.current; if (prevId && prevId !== selectedDoc?.id) { flushPendingSave(); releaseMyLock(prevId); + if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { + realtimeConnRef.current.send({ event: 'CRDT_LEAVE', docId: prevId }); + } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + try { + wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: prevId })); + } catch {} + } else { + api.clearDocumentAwareness(prevId).catch(() => {}); + } + if (crdtSessionRef.current) { + crdtSessionRef.current.destroy(); + crdtSessionRef.current = null; + } + setActivePeers([]); } currentDocIdRef.current = selectedDoc?.id || null; if (selectedDoc) { + const targetDoc = selectedDoc; + const session = new DocumentCRDTSession(targetDoc.id); + session.initFromBlocks(targetDoc.blocks || []); + session.onLocalUpdate = (b64Update) => { + if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { + realtimeConnRef.current.send({ + event: 'CRDT_UPDATE', + docId: targetDoc.id, + payload: { update: b64Update }, + }); + } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + try { + wsRef.current.send( + JSON.stringify({ + event: 'CRDT_UPDATE', + docId: targetDoc.id, + payload: { update: b64Update }, + }) + ); + } catch {} + } else { + api.saveDocumentCRDT(targetDoc.id, { update: b64Update }).catch(() => {}); + } + }; + session.onRemoteChange = (crdtBlocks) => { + setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev)); + setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d))); + }; + crdtSessionRef.current = session; + + if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { + realtimeConnRef.current.send({ event: 'CRDT_JOIN', docId: targetDoc.id }); + } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + try { + wsRef.current.send(JSON.stringify({ event: 'CRDT_JOIN', docId: targetDoc.id })); + } catch {} + } + + api + .getDocumentCRDT(targetDoc.id) + .then((res) => { + if (res && crdtSessionRef.current && crdtSessionRef.current.docId === targetDoc.id) { + if (res.state) { + crdtSessionRef.current.applyRemoteUpdate(res.state); + } + if (res.updates && Array.isArray(res.updates)) { + crdtSessionRef.current.applyRemoteUpdates(res.updates); + } + const blocks = crdtSessionRef.current.getBlocks(); + if (blocks.length > 0) { + setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev)); + setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d))); + } + } + }) + .catch(() => {}); + api .getDocLock(selectedDoc.id) .then((res) => { @@ -759,6 +884,19 @@ export const WorkspaceView: React.FC = ({ return () => { flushPendingSave(); if (currentDocIdRef.current) { + if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) { + realtimeConnRef.current.send({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current }); + } else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + try { + wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current })); + } catch {} + } else { + api.clearDocumentAwareness(currentDocIdRef.current).catch(() => {}); + } + if (crdtSessionRef.current) { + crdtSessionRef.current.destroy(); + crdtSessionRef.current = null; + } releaseMyLock(currentDocIdRef.current); } }; @@ -819,7 +957,47 @@ export const WorkspaceView: React.FC = ({ const handleWsMsg = (msg: any) => { if (!msg || !msg.event) return; - if (msg.event === 'DOC_LOCKED') { + if (msg.event === 'CRDT_INIT') { + const payload = msg.payload as { docId: string; state?: string; updates?: string[] }; + if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) { + if (payload.state) { + crdtSessionRef.current.applyRemoteUpdate(payload.state); + } + if (payload.updates && Array.isArray(payload.updates)) { + crdtSessionRef.current.applyRemoteUpdates(payload.updates); + } + const blocks = crdtSessionRef.current.getBlocks(); + if (blocks.length > 0) { + setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev)); + setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d))); + } + } + } else if (msg.event === 'CRDT_UPDATE') { + const payload = msg.payload as { docId: string; update: string; fromUserId?: string }; + if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) { + if (payload.fromUserId !== currentUserIdRef.current) { + crdtSessionRef.current.applyRemoteUpdate(payload.update); + const blocks = crdtSessionRef.current.getBlocks(); + if (blocks && blocks.length > 0) { + setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev)); + setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d))); + } + } + } + } else if (msg.event === 'CRDT_AWARENESS') { + const payload = (msg.payload?.payload || msg.payload) as CollaboratorAwareness; + if (payload && payload.userId && payload.userId !== currentUserIdRef.current) { + setActivePeers((prev) => { + const filtered = prev.filter((c) => c.userId !== payload.userId); + return [...filtered, { ...payload, color: getCollaboratorColor(payload.userId), lastActive: Date.now() }]; + }); + } + } else if (msg.event === 'CRDT_AWARENESS_LEAVE') { + const payload = msg.payload as { docId?: string; userId?: string }; + if (payload && payload.userId) { + setActivePeers((prev) => prev.filter((c) => c.userId !== payload.userId)); + } + } else if (msg.event === 'DOC_LOCKED') { const payload = msg.payload as DocLockInfo; if (payload && payload.docId === selectedDocIdRef.current) { if (payload.userId !== currentUserIdRef.current) { @@ -950,82 +1128,26 @@ export const WorkspaceView: React.FC = ({ } }; - const connect = () => { - if (!isMounted) return; - const token = getToken(); - if (!token) { - setWsConnected(false); - return; - } - - if (wsRef.current) { - try { - wsRef.current.close(); - } catch {} - wsRef.current = null; - } - - try { - const url = buildWebSocketURL(); - const ws = new WebSocket(url); - wsRef.current = ws; - - ws.onopen = () => { - if (!isMounted) return; - wsReconnectDelayRef.current = 2000; + const conn = createRealtimeConnection({ + docId: selectedDocIdRef.current || undefined, + onMessage: handleWsMsg, + onStatusChange: (status) => { + if (!isMounted) return; + setWsConnected(status.connected); + setRealtimeTransport(status.transport); + if (status.connected) { setWsDisconnectReason(null); - setWsConnected(true); + if (selectedDocIdRef.current) { + conn.send({ event: 'CRDT_JOIN', docId: selectedDocIdRef.current }); + } api.getFolders().then(setCustomFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); - }; - - ws.onclose = (evt: CloseEvent) => { - if (!isMounted) return; - setWsConnected(false); - const reasonText = - evt.reason || - (evt.code === 1006 - ? 'Verbindung durch Firmen-Proxy oder Netzwerk getrennt (Code 1006)' - : evt.code === 1008 - ? 'Richtlinien-Verletzung durch Proxy/Firewall (Code 1008)' - : evt.code === 1002 - ? 'Protokollfehler beim Handshake (Code 1002)' - : evt.code === 1000 - ? 'Normal geschlossen (Code 1000)' - : `Code ${evt.code}`); - console.warn('[MindSift Workspace WS] getrennt:', evt.code, evt.reason || reasonText); - setWsDisconnectReason(reasonText); - clearTimeout(wsReconnectTimerRef.current); - const delay = wsReconnectDelayRef.current; - wsReconnectDelayRef.current = Math.min(30000, Math.round(wsReconnectDelayRef.current * 1.5)); - wsReconnectTimerRef.current = setTimeout(connect, delay); - }; - - ws.onerror = (e) => { - if (!isMounted) return; - console.warn('[MindSift Workspace WS] Fehler aufgetreten:', e); - setWsConnected(false); - }; - - ws.onmessage = (evt) => { - try { - const msg = JSON.parse(evt.data); - handleWsMsg(msg); - } catch {} - }; - } catch (err: any) { - if (!isMounted) return; - setWsConnected(false); - const errText = err?.message || 'Verbindungsfehler'; - setWsDisconnectReason(errText); - clearTimeout(wsReconnectTimerRef.current); - const delay = wsReconnectDelayRef.current; - wsReconnectDelayRef.current = Math.min(30000, Math.round(wsReconnectDelayRef.current * 1.5)); - wsReconnectTimerRef.current = setTimeout(connect, delay); - } - }; - - connect(); + } else { + setWsDisconnectReason(status.reason || 'Verbindung getrennt'); + } + }, + }); + realtimeConnRef.current = conn; // Also synchronize with global WebSocket status const unsubGlobalStatus = onWebSocketStatus((connected) => { @@ -1047,12 +1169,11 @@ export const WorkspaceView: React.FC = ({ return () => { isMounted = false; unsubGlobalStatus(); - clearTimeout(wsReconnectTimerRef.current); - if (wsRef.current) { + if (realtimeConnRef.current) { try { - wsRef.current.close(); + realtimeConnRef.current.close(); } catch {} - wsRef.current = null; + realtimeConnRef.current = null; } window.removeEventListener('mindsift:ws', handleGlobalCustomEvent); }; @@ -2035,23 +2156,23 @@ export const WorkspaceView: React.FC = ({ if (!activeEditingBlockId) return; const el = blockInputRefs.current[activeEditingBlockId]; if (!el) return; - const segStartAttr = el.getAttribute('data-seg-start'); - if (segStartAttr !== null && 'setSelectionRange' in el && typeof el.value === 'string') { - const segStart = parseInt(segStartAttr, 10) || 0; + if (el instanceof HTMLTextAreaElement) { + autoResizeTextarea(el); + } + // Only programmatically adjust selection if the element is NOT already the actively focused element! + if (document.activeElement !== el && 'setSelectionRange' in el && typeof el.value === 'string') { + const segStartAttr = el.getAttribute('data-seg-start'); + const segStart = segStartAttr !== null ? parseInt(segStartAttr, 10) || 0 : 0; const desiredOffset = activeCursorOffsetRef.current; const localPos = Math.max(0, Math.min(el.value.length, desiredOffset - segStart)); - if (document.activeElement !== el) { - el.focus(); - } + el.focus(); try { - if (el.selectionStart !== localPos || el.selectionEnd !== localPos) { - el.setSelectionRange(localPos, localPos); - } + el.setSelectionRange(localPos, localPos); } catch { // Ignore selection range errors on unsupported inputs } } - }, [activeEditingBlockId, activeCursorOffset, selectedDoc]); + }, [activeEditingBlockId]); // Keyboard Navigation: Enter, Shift+Enter, Tab, Shift+Tab, Backspace, / Slash Commands const handleKeyDown = ( @@ -2112,6 +2233,9 @@ export const WorkspaceView: React.FC = ({ const nextCursor = cursor + 1; setBlockCursor(block.id, nextCursor); updateBlock(block.id, { content: nextContent }); + if (target instanceof HTMLTextAreaElement) { + setTimeout(() => autoResizeTextarea(target), 0); + } } return; } @@ -2124,7 +2248,7 @@ export const WorkspaceView: React.FC = ({ let afterContent = ''; let updatedCurrent: { id: string; content: string } | undefined = undefined; - if (!fullContent.includes('\n') && target && typeof target.selectionStart === 'number') { + if (target && typeof target.selectionStart === 'number') { const cursor = segStart + target.selectionStart; const before = fullContent.slice(0, cursor); const after = fullContent.slice(cursor); @@ -2133,7 +2257,6 @@ export const WorkspaceView: React.FC = ({ afterContent = after; } } else if (fullContent.endsWith('\n')) { - // Clean up trailing empty line if user pressed Shift+Enter then immediately Enter const trimmedTrailing = fullContent.replace(/\n+$/, ''); if (trimmedTrailing !== fullContent) { updatedCurrent = { id: block.id, content: trimmedTrailing }; @@ -2164,90 +2287,61 @@ export const WorkspaceView: React.FC = ({ deleteSingleBlock(block.id, true); return; } else if ( - segStart > 0 && target && target.selectionStart === 0 && target.selectionEnd === 0 ) { e.preventDefault(); - if (fullContent[segStart - 1] === '\n') { - // Join current line with previous line within the same bullet - const joined = fullContent.slice(0, segStart - 1) + fullContent.slice(segStart); - setBlockCursor(block.id, segStart - 1); - handleContentChange(block, joined); - } else { - // Move cursor into preceding inline token to unwrap it - setBlockCursor(block.id, segStart - 1); + if ((block.level || 0) > 0) { + outdentBlock(block.id); + return; + } + if (selectedDoc && index > 0) { + const prevBlock = selectedDoc.blocks[index - 1]; + if (prevBlock && prevBlock.type !== 'divider') { + const prevLen = (prevBlock.content || '').length; + const mergedContent = (prevBlock.content || '') + fullContent; + const nextBlocks = selectedDoc.blocks + .map((b) => (b.id === prevBlock.id ? { ...b, content: mergedContent } : b)) + .filter((b) => b.id !== block.id); + persistDocument({ ...selectedDoc, blocks: nextBlocks }, true); + focusBlock(prevBlock.id, prevLen); + } } } - } else if (!e.shiftKey && e.key === 'ArrowLeft') { - const target = e.target as HTMLTextAreaElement | HTMLInputElement; - if (segStart > 0 && target && target.selectionStart === 0 && target.selectionEnd === 0) { - e.preventDefault(); - setBlockCursor(block.id, segStart - 1); - return; - } - requestAnimationFrame(() => { - if (target && typeof target.selectionStart === 'number') { - setBlockCursor(block.id, segStart + target.selectionStart); - } - }); - } else if (!e.shiftKey && e.key === 'ArrowRight') { - const target = e.target as HTMLTextAreaElement | HTMLInputElement; - const fullContent = block.content || ''; - if ( - target && - typeof target.selectionStart === 'number' && - target.selectionStart === target.value.length && - segStart + target.value.length < fullContent.length - ) { - e.preventDefault(); - setBlockCursor(block.id, segStart + target.value.length + 1); - return; - } - requestAnimationFrame(() => { - if (target && typeof target.selectionStart === 'number') { - setBlockCursor(block.id, segStart + target.selectionStart); - } - }); } else if (!e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { const target = e.target as HTMLTextAreaElement | HTMLInputElement; const fullContent = block.content || ''; - if (fullContent.includes('\n') && target && typeof target.selectionStart === 'number') { - const cursor = segStart + target.selectionStart; - const rawLines = fullContent.split('\n'); - let run = 0; - const lineMeta = rawLines.map((lt) => { - const start = run; - const end = start + lt.length; - run = end + 1; - return { start, end, len: lt.length }; - }); - const curLineIdx = lineMeta.findIndex((m) => cursor >= m.start && cursor <= m.end); - if (curLineIdx !== -1) { - const col = cursor - lineMeta[curLineIdx].start; - if (e.key === 'ArrowUp' && curLineIdx > 0) { - e.preventDefault(); - const prevLine = lineMeta[curLineIdx - 1]; - setBlockCursor(block.id, prevLine.start + Math.min(col, prevLine.len)); - return; + if (target && typeof target.selectionStart === 'number') { + const cursor = target.selectionStart; + if (e.key === 'ArrowUp') { + const firstNewline = fullContent.indexOf('\n'); + if (firstNewline === -1 || cursor <= firstNewline) { + if (index > 0 && selectedDoc) { + const prev = selectedDoc.blocks[index - 1]; + if (prev) { + e.preventDefault(); + focusBlock(prev.id); + return; + } + } } - if (e.key === 'ArrowDown' && curLineIdx < lineMeta.length - 1) { - e.preventDefault(); - const nextLine = lineMeta[curLineIdx + 1]; - setBlockCursor(block.id, nextLine.start + Math.min(col, nextLine.len)); - return; + } else if (e.key === 'ArrowDown') { + const lastNewline = fullContent.lastIndexOf('\n'); + if (lastNewline === -1 || cursor > lastNewline) { + if (selectedDoc && index < selectedDoc.blocks.length - 1) { + const next = selectedDoc.blocks[index + 1]; + if (next) { + e.preventDefault(); + focusBlock(next.id, 0); + return; + } + } } } } - } else if (!e.shiftKey && (e.key === 'Home' || e.key === 'End')) { - const target = e.target as HTMLTextAreaElement | HTMLInputElement; - requestAnimationFrame(() => { - if (target && typeof target.selectionStart === 'number') { - setBlockCursor(block.id, segStart + target.selectionStart); - } - }); } else if (e.key === 'Escape') { + setSlashBlockId(null); setActiveEditingBlockId(null); (e.target as HTMLElement)?.blur(); } @@ -2339,6 +2433,9 @@ export const WorkspaceView: React.FC = ({ const tryFocus = () => { const el = blockInputRefs.current[blockId]; if (el) { + if (el instanceof HTMLTextAreaElement) { + autoResizeTextarea(el); + } el.focus(); if ('setSelectionRange' in el && typeof (el as any).value === 'string') { const segStartAttr = el.getAttribute('data-seg-start'); @@ -2917,8 +3014,9 @@ export const WorkspaceView: React.FC = ({ ); }; - // Live inline token-aware editor: splits multi-line bullets (Shift+Enter) into visual rows and renders - // completed #hashtags and links inline as pills on each line while unwrapping any token touched by the cursor. + // Auto-resizing textarea / formatted view for bullet points and tasks: + // - When editing: renders an auto-expanding textarea that naturally wraps text (no horizontal scrolling). + // - When inactive: renders full rich tokens (Wiki-Links, external URLs, #hashtags) with word wrapping. const renderLiveInlineBlock = ( block: DocumentBlock, originalIndex: number, @@ -2927,240 +3025,97 @@ export const WorkspaceView: React.FC = ({ const text = block.content || ''; if (isReadOnly) { return ( -
+
{renderFormattedText(text, block.id)}
); } const isEditing = activeEditingBlockId === block.id; - const cursor = isEditing ? Math.max(0, Math.min(text.length, activeCursorOffset)) : text.length; - const rawLines = text.split('\n'); - let runningOffset = 0; - const lines = rawLines.map((lineText, lineIdx) => { - const lineStart = runningOffset; - const lineEnd = lineStart + lineText.length; - runningOffset = lineEnd + 1; // +1 for the '\n' delimiter - return { lineIdx, lineText, lineStart, lineEnd }; - }); - - let activeLineIdx = lines.findIndex((l) => cursor >= l.lineStart && cursor <= l.lineEnd); - if (activeLineIdx === -1) { - activeLineIdx = lines.length - 1; + if (!isEditing) { + return ( +
{ + if (isReadOnly) return; + if ((e.target as HTMLElement).closest('button, a')) return; + let clickOffset: number | undefined = undefined; + try { + if (document.caretRangeFromPoint) { + const range = document.caretRangeFromPoint(e.clientX, e.clientY); + if (range && e.currentTarget.contains(range.startContainer)) { + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(e.currentTarget); + preCaretRange.setEnd(range.endContainer, range.endOffset); + clickOffset = preCaretRange.toString().length; + } + } else if ((document as any).caretPositionFromPoint) { + const pos = (document as any).caretPositionFromPoint(e.clientX, e.clientY); + if (pos && pos.offsetNode && e.currentTarget.contains(pos.offsetNode)) { + const range = document.createRange(); + range.selectNodeContents(e.currentTarget); + range.setEnd(pos.offsetNode, pos.offset); + clickOffset = range.toString().length; + } + } + } catch { + // fallback to end of text + } + focusBlock(block.id, clickOffset); + }} + className={`w-full text-sm leading-relaxed break-words whitespace-pre-wrap min-h-[22px] cursor-text ${extraTextClass}`} + > + {text ? ( + renderFormattedText(text, block.id) + ) : ( +   + )} +
+ ); } - type InlineSegment = - | { kind: 'text'; start: number; end: number; text: string } - | { kind: 'token'; start: number; end: number; raw: string }; - return ( -
- {lines.map((line) => { - const isLineActive = isEditing && line.lineIdx === activeLineIdx; - const lineTokens = findRichTokens(line.lineText).map((t) => ({ - start: line.lineStart + t.start, - end: line.lineStart + t.end, - raw: t.raw, - })); - - // Non-active line (or block not currently focused) - if (!isLineActive) { - const segments: InlineSegment[] = []; - let lastIdx = line.lineStart; - for (const t of lineTokens) { - if (t.start > lastIdx) { - segments.push({ - kind: 'text', - start: lastIdx, - end: t.start, - text: text.slice(lastIdx, t.start), - }); - } - segments.push({ kind: 'token', start: t.start, end: t.end, raw: t.raw }); - lastIdx = t.end; - } - if (lastIdx < line.lineEnd) { - segments.push({ - kind: 'text', - start: lastIdx, - end: line.lineEnd, - text: text.slice(lastIdx, line.lineEnd), - }); - } - - return ( -
{ - if (e.target === e.currentTarget) { - e.preventDefault(); - focusBlock(block.id, line.lineEnd); - } - }} - onClick={(e) => { - if (e.target === e.currentTarget) { - focusBlock(block.id, line.lineEnd); - } - }} - className="flex flex-wrap items-center gap-y-0.5 w-full text-sm leading-relaxed break-words min-h-[22px] cursor-text" - > - {segments.length === 0 ? ( - { - e.preventDefault(); - focusBlock(block.id, line.lineStart); - }} - className="w-full min-h-[22px] inline-block" - > -   - - ) : ( - segments.map((seg, i) => - seg.kind === 'token' ? ( - renderSingleRichToken(seg.raw, block.id, `tok-${seg.start}-${i}`) - ) : ( - { - e.stopPropagation(); - const sel = window.getSelection(); - const localOffset = - sel && sel.anchorNode && e.currentTarget.contains(sel.anchorNode) - ? sel.anchorOffset - : seg.text.length; - focusBlock(block.id, seg.start + localOffset); - }} - className="whitespace-pre-wrap" - > - {seg.text} - - ) - ) - )} -
- ); +