diff --git a/Doku/api/rest.md b/Doku/api/rest.md index e1bcb8a..e9b630b 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -104,7 +104,7 @@ Grundlage: [plans/kollaboration-notizen.md](../plans/kollaboration-notizen.md). } ``` -- Operationsarten: `insert`, `set_content`, `set_props` (`patch`: `type`, `checked`, `collapsed`, `lang`), `move`, `delete`, `restore` (`ids`), `set_title` (`baseVersion`, `title`). +- Operationsarten: `insert`, `set_content`, `set_props` (`patch`: `type`, `checked`, `collapsed`, `lang`, `clearConflict`), `move`, `delete`, `restore` (`ids`), `set_title` (`baseVersion`, `title`). - `leases.hold` ist die vollständige Liste der Blöcke, die der Client halten will; alle anderen Leases des Clients werden freigegeben. Neu eingefügte Blöcke und Konfliktkopien bekommt der Autor automatisch. - Höchstens 500 Operationen pro Anfrage. Leser dürfen keine Operationen senden (`403`), aber abfragen. diff --git a/Doku/flaws.md b/Doku/flaws.md index 47b44b3..1dfd6f1 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -34,8 +34,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig | | FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig | | FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel | -| FLAW-25 | Workspace | Zusammenführen/Einrücken an einem fremd gesperrten Bullet wird nur teilweise ausgeführt | Mittel | -| FLAW-26 | Workspace | Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet | Niedrig | +| FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** | +| FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** | --- @@ -176,11 +176,13 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen. ### FLAW-25: Teilweise ausgeführte Struktur-Aktionen an gesperrten Bullets +- **Status: Behoben.** Fremd gesperrte Bullets sind im Editor schreibgeschützt und zeigen, wer sie bearbeitet. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden nicht gestartet, wenn ein beteiligter Bullet (oder einer im Ast) gesperrt ist; stattdessen erscheint ein Hinweis. Ebenso für „Übernehmen“/„Verwerfen“ und Rückgängig. Browser-Test: `e2e/lock.e2e.mjs`. - **Fundstellen:** `handleMergeWithPrevious` und `handleIndentBlock` in [`OutlinerTree.tsx`](../frontend/src/features/workspace/OutlinerTree.tsx); Sperrregeln in `internal/collab` (Verschieben/Löschen gesperrter Blöcke wird abgelehnt). - **Problem:** Eine Editor-Aktion besteht oft aus mehreren Operationen. Ist einer der beteiligten Bullets von jemand anderem gesperrt, lehnt der Server nur diese Operation ab. Beispiel aus dem Browser-Test: Backspace am Zeilenanfang hängt den Text oben an (angewendet), das Löschen des unteren Bullets wird abgelehnt – der Text steht dann doppelt da. Einrücken eines fremd gesperrten Bullets wird ohne Hinweis zurückgenommen. Es geht kein Text verloren. - **Maßnahme (Phase 3):** Gesperrte Bullets im Editor anzeigen und Struktur-Aktionen, die sie berühren, gar nicht erst ausführen. ### FLAW-26: Fokus-Verzögerung nach Enter +- **Status: Behoben.** Neu eingehängte Bullets bekommen den Fokus sofort beim Einhängen. Browser-Test: „Sofort nach Enter Getipptes landet im neuen Bullet“. - **Fundstelle:** `focusBlockInput` in `OutlinerTree.tsx` setzt den Fokus erst im nächsten Animation-Frame. - **Problem:** Zeichen, die innerhalb dieses Frames getippt werden (automatisiert oder sehr schnelles Tippen), landen noch im vorherigen Bullet. Bei fremd gesperrtem Bullet entsteht daraus eine Konfliktkopie. - **Maßnahme:** Fokus nach dem Anlegen synchron setzen (`useLayoutEffect`). diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 4dcc6a1..7d06be3 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -20,7 +20,10 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline. - **Abgleich:** Bei geteilten oder mehrfach geöffneten Notizen fragt die Engine alle 3 s nach Änderungen, sonst alle 30 s, sowie sofort bei Rückkehr ins Netz oder in den Tab. Beim Verlassen sendet sie Ausstehendes mit `keepalive` und gibt ihre Sperren frei. - **Textkonflikte:** Jede Textänderung trägt die Version, die der Nutzer beim Tippen vor sich hatte. Hat jemand anderes den Bullet inzwischen geändert oder gesperrt, legt der Server eine Konfliktkopie an; es wird nie still überschrieben. -- **Sperren:** Der Bullet mit dem Cursor, verlassene Bullets für 20 s und neu angelegte Bullets werden gehalten. Die Anzeige gesperrter Bullets folgt in Phase 3 des [Plans](../plans/kollaboration-notizen.md). +- **Sperren:** Der Bullet mit dem Cursor, verlassene Bullets für 20 s und neu angelegte Bullets werden gehalten. Bullets, die jemand anderes hält, zeigen ein Schloss mit Namen und sind schreibgeschützt; Enter darin legt einen neuen Bullet darunter an. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden bei gesperrten Bullets nicht ausgeführt (Hinweis unten im Editor). +- **Präsenz:** Im Kopf der Notiz steht, wer sie gerade sonst noch geöffnet hat. +- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“. +- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen. - Den bisherigen Speicherweg (`PUT /documents/{id}`) nutzt der Editor nur noch für Symbol und Anheften; Titel und Inhalt laufen über die Engine. `PATCH /documents/{id}/blocks` nutzt das To-Do-Rollup; beide Wege landen im selben Änderungs-Log. ## Explorer, Tags, To-Dos diff --git a/Doku/plans/kollaboration-notizen.md b/Doku/plans/kollaboration-notizen.md index 50422a3..e7d194b 100644 --- a/Doku/plans/kollaboration-notizen.md +++ b/Doku/plans/kollaboration-notizen.md @@ -280,7 +280,7 @@ Damit private Notizen keinen unnötigen Aufwand erzeugen, liefert der Server im | 0 | Sicherheitsfixes vorziehen: Raumberechtigung (FLAW-01/02), Umsortieren (FLAW-03), Stream-Timeout (FLAW-15). **Erledigt.** Die zentrale `docRole` wandert in Phase 1, weil sie auf dem neuen Freigabemodell (`workspace_access`) aufbaut. | Grundlage dicht | | 1 | Operations-Log, `/snapshot`, `/sync`, `/changes`, `/leave`, Konfliktregeln, Leases, zentrale `DocRole`, Simulationstests. **Erledigt.** Abweichungen: Die bestehenden Tabellen `blocks`/`documents` wurden erweitert statt ersetzt (Baum per `parent_id`/`order_key`, alte Daten werden umgewandelt); der bisherige Speicherweg (`PUT`/`PATCH`) läuft als Adapter durch dieselbe Engine. `DocRole` arbeitet auf den vorhandenen Freigabetabellen; deren Zusammenlegung zu `workspace_access` erfolgt mit dem Freigabe-Dialog in Phase 5. `/changes` liefert nur `{headSeq, changed}`, die Daten kommen immer über `/sync`. | Server fertig und abgesichert | | 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt. **Erledigt.** Abweichung: Die Editor-Komponenten arbeiten weiter mit der flachen Liste (Ebene + Reihenfolge); die Engine übersetzt jede Änderung per Vergleich in Operationen (`lib/docSync/diff.ts`). Sperren werden bereits gehalten (Fokus, 20 s Nachlauf, neue Bullets), aber noch nicht angezeigt. Ende-zu-Ende-Test mit zwei Browsern: `e2e/`. | Bearbeiten funktioniert überall, auch offline | -| 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen. Dazu aus Phase 2: gesperrte Bullets schreibgeschützt anzeigen und Struktur-Aktionen auf ihnen gar nicht erst starten (sonst kann Zusammenführen halb ausgeführt werden, siehe FLAW-25); Fokuswechsel nach Enter ohne Frame-Verzögerung (FLAW-26). | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z | +| 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen, FLAW-25/26. **Erledigt.** Ergänzungen: Enter in einem fremd gesperrten Bullet legt einen neuen Bullet darunter an (der gesperrte bleibt unberührt); „Übernehmen“/„Verwerfen“ prüfen auch die Sperre der Kopie selbst. | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z | | 4 | Push-Stufen WebSocket → SSE → Long-Polling mit automatischer Erkennung | Live-Gefühl, wo das Netz es zulässt | | 5 | Freigabe-Dialog mit Bearbeiter-Rolle (**vorgezogen und erledigt**, für Notizen und geteilte Ordner), Ordnerfreigaben, Altlasten entfernen (`PUT`-Blockersatz, `PATCH /blocks`, `doc_shares`, `doc_team_shares`, Zwangsmigration auf „read“) | Feature komplett | | 6 | Härtung: Playwright-Tests, Log-Kompaktierung (Snapshot statt Log für Clients mit sehr altem `lastSeq`), Aufräumen der Grabsteine | Langzeitbetrieb | diff --git a/e2e/README.md b/e2e/README.md index 26b1d18..e485079 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -28,4 +28,8 @@ Zwei Browser (Eigentümer und Bearbeiter) arbeiten in derselben Notiz. Geprüft Der Eigentümer teilt eine Notiz über den Freigabe-Dialog mit Rolle „Bearbeiten“; geprüft wird, dass die Freigabe als `write` gespeichert, im Dialog als „Bearbeiter“ angezeigt wird und der Empfänger die Notiz bearbeiten kann. -`collab.e2e.mjs` dauert wegen des Sperrablaufs etwa eine Minute. Er läuft nicht in der CI, weil er Browser-Images braucht. +## lock.e2e.mjs + +Sperren, Präsenz, Rückgängig und Konfliktkopien mit zwei Browsern: Schloss und Schreibschutz am fremd bearbeiteten Bullet, Hinweis statt halb ausgeführter Aktion, `Strg+Z`/`Strg+Umschalt+Z`, Tippen direkt nach Enter, Markierung von Konfliktkopien sowie „Übernehmen“ (verweigert bei fremder Sperre, erfolgreich für den Sperrinhaber). + +`collab.e2e.mjs` und `lock.e2e.mjs` dauern wegen des Sperrablaufs je etwa eine Minute. Er läuft nicht in der CI, weil er Browser-Images braucht. diff --git a/e2e/collab.e2e.mjs b/e2e/collab.e2e.mjs index 10d6446..577b7b3 100644 --- a/e2e/collab.e2e.mjs +++ b/e2e/collab.e2e.mjs @@ -135,6 +135,7 @@ const main = async () => { return probe.leases.others.length === 0; }, 25000); check(!!freed, 'Sperren eines geschlossenen Browsers laufen nach spätestens 20 s ab'); + await sleep(3500); // the admin's browser learns about it with its next sync (every 3 s in shared notes) await A.page.getByText('Von B', { exact: true }).first().click(); await A.page.keyboard.press('Home'); await A.page.keyboard.press('Tab'); diff --git a/e2e/lock.e2e.mjs b/e2e/lock.e2e.mjs new file mode 100644 index 0000000..f998d74 --- /dev/null +++ b/e2e/lock.e2e.mjs @@ -0,0 +1,170 @@ +// Locks, presence, undo and conflict copies with two real browsers. Needs a server in demo mode +// (accounts admin/admin and user/user); see e2e/README.md. +import { chromium } from 'playwright'; + +const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let failures = 0; +const check = (cond, msg) => { + console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`); + if (!cond) failures++; +}; + +async function api(token, method, path, body) { + const res = await fetch(BASE + path, { + method, + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`); + return text ? JSON.parse(text) : null; +} + +async function waitFor(fn, ms) { + const end = Date.now() + ms; + while (Date.now() < end) { + try { + const v = await fn(); + if (v) return v; + } catch {} + await sleep(250); + } + return null; +} + +const serverBlocks = async (token, docId) => + (await api(token, 'GET', `/api/v1/docs/${docId}/snapshot`)).blocks.filter((b) => !b.deleted); +const editorValues = (page) => + page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value)); + +// Puts the cursor at the end of the text field showing `text` (works whether or not it is focused already) +async function focusField(page, text) { + const handle = await page.waitForFunction( + (t) => Array.from(document.querySelectorAll('textarea')).find((e) => e.value === t) || null, + text, + { timeout: 5000 } + ); + const el = handle.asElement(); + await el.click(); + await page.keyboard.press('End'); +} + +async function openDoc(browser, token, title) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token); + const page = await ctx.newPage(); + await page.goto(BASE); + await page.getByRole('button', { name: /Workspace/ }).first().click(); + await page.getByText(title, { exact: true }).first().click(); + await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 }); + return { ctx, page }; +} + +const TITLE = `Sperr-Test ${Date.now()}`; +const id = (n) => `lk_${n}_${Date.now()}`; + +const main = async () => { + const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token; + const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token; + const dir = await api(admin, 'GET', '/api/v1/users/directory'); + const userId = dir.find((u) => u.username === 'user' || u.email?.startsWith('user')).id; + const ids = { a: id('a'), b: id('b') }; + const doc = await api(admin, 'POST', '/api/v1/documents', { + title: TITLE, + blocks: [ + { id: ids.a, type: 'bullet', content: 'Admin schreibt hier', level: 0 }, + { id: ids.b, type: 'bullet', content: 'Darunter', level: 0 }, + ], + }); + await api(admin, 'POST', `/api/v1/documents/${doc.id}/shares`, { userId, permission: 'write' }); + + const browser = await chromium.launch(); + const A = await openDoc(browser, admin, TITLE); + const B = await openDoc(browser, user, TITLE); + + // Presence + const presence = await waitFor(async () => B.page.getByTitle('Hat diese Notiz gerade geöffnet').innerText(), 8000); + check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`); + + // Admin clicks into the first bullet -> user sees the lock + await A.page.getByText('Admin schreibt hier', { exact: true }).first().click(); + const lockBadge = await waitFor(async () => (await B.page.getByTitle(/bearbeitet diesen Punkt gerade/).count()) > 0, 10000); + check(!!lockBadge, 'Bearbeiter sieht das Schloss am gesperrten Bullet'); + const readOnly = await B.page.$$eval('textarea', (els) => els.find((e) => e.value === 'Admin schreibt hier')?.readOnly); + check(readOnly === true, 'Gesperrter Bullet ist beim Bearbeiter schreibgeschützt'); + + // User tries to merge "Darunter" into the locked bullet + await B.page.getByText('Darunter', { exact: true }).first().click(); + await B.page.keyboard.press('Home'); + await B.page.keyboard.press('Backspace'); + const notice = await waitFor(async () => B.page.getByRole('status').innerText(), 3000); + check(!!notice && /bearbeitet diesen Punkt gerade/.test(notice), `Hinweis statt halber Aktion: ${notice}`); + await sleep(1500); + const afterMerge = (await serverBlocks(admin, doc.id)).map((b) => b.content).sort(); + check(JSON.stringify(afterMerge) === JSON.stringify(['Admin schreibt hier', 'Darunter']), `Nichts wurde teilweise ausgeführt: ${JSON.stringify(afterMerge)}`); + + // Undo on the admin side + await focusField(A.page, 'Admin schreibt hier'); + await A.page.keyboard.type(' und noch mehr'); + await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000); + await A.page.keyboard.press('Control+z'); + const undone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier'), 5000); + check(!!undone, 'Strg+Z nimmt den getippten Text zurück (auch auf dem Server)'); + const fieldUndone = await waitFor(async () => (await editorValues(A.page)).includes('Admin schreibt hier'), 3000); + check(!!fieldUndone, 'Das fokussierte Feld zeigt den zurückgenommenen Text'); + await A.page.keyboard.press('Control+Shift+z'); + const redone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000); + check(!!redone, 'Strg+Umschalt+Z stellt ihn wieder her'); + + // Typing right after Enter lands in the new bullet (no pause) + await A.page.keyboard.press('End'); + await A.page.keyboard.press('Enter'); + await A.page.keyboard.type('Direkt nach Enter'); + const newBullet = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Direkt nach Enter'), 5000); + check(!!newBullet, 'Sofort nach Enter Getipptes landet im neuen Bullet'); + + // Conflict copy: two writes on the same base version via the API + const before = (await serverBlocks(admin, doc.id)).find((b) => b.id === ids.b); + await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-1', lastSeq: 0, ops: [{ opId: `${ids.b}-x`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Gewinner-Text' }] }); + await api(user, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-2', lastSeq: 0, ops: [{ opId: `${ids.b}-y`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Verlierer-Text' }] }); + const conflictShown = await waitFor(async () => (await A.page.getByText(/Version von/).count()) > 0, 8000); + check(!!conflictShown, 'Konfliktkopie wird markiert angezeigt'); + // The user's cursor is still in the original bullet (it holds the lock), so the admin may not overwrite it + await A.page.getByRole('button', { name: 'Übernehmen' }).first().click(); + const refused = await waitFor(async () => A.page.getByRole('status').innerText(), 3000); + check(!!refused && /bearbeitet/.test(refused), `Übernehmen in einen fremd gesperrten Punkt wird verweigert: ${refused}`); + // The user holding the original can accept once the copy's author (here a separate API client that never + // renews its lease) no longer holds the copy, i.e. after at most 20 s + await waitFor(async () => (await B.page.getByText(/Version von/).count()) > 0, 8000); + const copyId = (await serverBlocks(admin, doc.id)).find((b) => b.props?.conflictOf)?.id; + const copyFree = await waitFor(async () => { + const probe = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'probe', lastSeq: 0 }); + return !probe.leases.others.some((l) => l.blockId === copyId); + }, 25000); + check(!!copyFree, 'Sperre der Konfliktkopie läuft ab'); + await sleep(3500); // the user's browser learns about it with its next sync + // Both API writes became conflict copies (the user's cursor locked the original); accept the first one + const copiesBefore = (await serverBlocks(admin, doc.id)).filter((b) => b.props?.conflictOf === ids.b); + const firstCopyText = await B.page.$$eval( + 'textarea', + (els, texts) => els.map((e) => e.value).find((v) => texts.includes(v)), + copiesBefore.map((c) => c.content) + ); + await B.page.getByRole('button', { name: 'Übernehmen' }).first().click(); + const accepted = await waitFor(async () => { + const blocks = await serverBlocks(admin, doc.id); + const copiesNow = blocks.filter((b) => b.props?.conflictOf === ids.b); + return blocks.find((b) => b.id === ids.b)?.content === firstCopyText && copiesNow.length === copiesBefore.length - 1; + }, 6000); + check(!!accepted, `„Übernehmen“ ersetzt den ursprünglichen Text durch „${firstCopyText}“ und entfernt diese Kopie`); + + await browser.close(); + console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN'); + process.exit(failures ? 1 : 0); +}; + +main().catch((e) => { + console.error(e); + process.exit(2); +}); diff --git a/e2e/run.sh b/e2e/run.sh index d047786..64efd7b 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -15,4 +15,4 @@ for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev echo "Starte Browser-Tests …" "$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ - sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs' + sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs' diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index c533eff..e2af139 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -405,6 +405,50 @@ export const DocumentEditor: React.FC = ({ } }, [engineState?.status, engineState?.errorMessage]); + // Blocks other users are editing right now (blockId -> name) + const lockedBlocks = useMemo(() => { + const m = new Map(); + for (const l of engineState?.lockedByOthers || []) m.set(l.blockId, l.name || 'Jemand'); + return m; + }, [engineState?.lockedByOthers]); + + const otherPeople = useMemo( + () => (engineState?.presence || []).filter((p) => p.userId !== currentUser?.id), + [engineState?.presence, currentUser?.id] + ); + + // Short hint at the bottom of the editor (blocked actions, skipped undo) + const [notice, setNotice] = useState(null); + const noticeTimerRef = useRef(null); + const showNotice = useCallback((message: string) => { + setNotice(message); + if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); + noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500); + }, []); + useEffect(() => () => { + if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); + }, []); + + const handleLockedAction = useCallback( + (holder: string) => showNotice(`${holder} bearbeitet diesen Punkt gerade – bitte kurz warten`), + [showNotice] + ); + const handleUndo = useCallback(() => { + const res = engineRef.current?.undo(); + if (res?.message) showNotice(res.message); + }, [showNotice]); + const handleRedo = useCallback(() => { + const res = engineRef.current?.redo(); + if (res?.message) showNotice(res.message); + }, [showNotice]); + const handleResolveConflict = useCallback( + (blockId: string, accept: boolean) => { + const res = engineRef.current?.resolveConflict(blockId, accept); + if (res?.message) showNotice(res.message); + }, + [showNotice] + ); + // Every local block change goes to the engine, which turns it into operations const applyLocalBlocks = useCallback( (update: DocumentBlock[] | ((prev: DocumentBlock[]) => DocumentBlock[])) => { @@ -722,6 +766,16 @@ export const DocumentEditor: React.FC = ({ {/* Right Action Buttons */}
+ {/* Who else has this note open */} + {otherPeople.length > 0 && ( + + + {otherPeople.map((p) => p.name).join(', ')} + + )} {/* Sharing Section */} {doc && (() => { const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); @@ -907,6 +961,11 @@ export const DocumentEditor: React.FC = ({ onWikiLinkClick={onWikiLinkClick} initialFocusedBlockId={initialFocusedBlockId} onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} + lockedBlocks={lockedBlocks} + onLockedAction={handleLockedAction} + onResolveConflict={handleResolveConflict} + onUndo={handleUndo} + onRedo={handleRedo} /> )}
@@ -1014,6 +1073,12 @@ export const DocumentEditor: React.FC = ({ e.target.style.height = `${e.target.scrollHeight}px`; }} onKeyDown={(e) => { + if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) { + e.preventDefault(); + if (e.key.toLowerCase() === 'y' || e.shiftKey) handleRedo(); + else handleUndo(); + return; + } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); outlinerTreeRef.current?.focusFirstBlock(); @@ -1044,10 +1109,24 @@ export const DocumentEditor: React.FC = ({ onWikiLinkClick={onWikiLinkClick} initialFocusedBlockId={initialFocusedBlockId} onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} + lockedBlocks={lockedBlocks} + onLockedAction={handleLockedAction} + onResolveConflict={handleResolveConflict} + onUndo={handleUndo} + onRedo={handleRedo} /> )} + + {notice && ( +
+ {notice} +
+ )} ); }; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index b0cdc06..9a28d14 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -18,6 +18,8 @@ import { Maximize2, Link as LinkIcon, FileText, + Lock, + AlertTriangle, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; @@ -70,6 +72,11 @@ export interface OutlinerBlockProps { isTargeted?: boolean; registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void; onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void; + lockedBy?: string; // someone else is editing this block + canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below + onResolveConflict?: (blockId: string, accept: boolean) => void; + onUndo?: () => void; + onRedo?: () => void; } // Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw @@ -112,10 +119,17 @@ export const OutlinerBlock: React.FC = ({ isTargeted, registerTextareaRef, onPasteMultiLines, + lockedBy, + canAppendBelow, + onResolveConflict, + onUndo, + onRedo, }) => { const [localContent, setLocalContent] = useState(block.content || ''); const [isLocallyFocused, setIsLocallyFocused] = useState(false); const activeFocus = isFocused || isLocallyFocused; + // A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end + const canFocus = canEdit || (!!lockedBy && !!canAppendBelow); const [slashSearch, setSlashSearch] = useState(null); const [slashIndex, setSlashIndex] = useState(0); const [showLinkModal, setShowLinkModal] = useState(false); @@ -164,6 +178,13 @@ export const OutlinerBlock: React.FC = ({ [] ); + // Right after undo/redo the focused field must show the engine's text, not keep its own + const acceptRemoteUntilRef = useRef(0); + const onUndoRef = useRef(onUndo); + onUndoRef.current = onUndo; + const onRedoRef = useRef(onRedo); + onRedoRef.current = onRedo; + // Sync incoming remote changes ONLY when this textarea is NOT actively being edited // Condition: isFocusedRef.current === true && document.hasFocus() useEffect(() => { @@ -174,7 +195,8 @@ export const OutlinerBlock: React.FC = ({ textareaRef.current !== null && document.activeElement === textareaRef.current; - if (!isActivelyEditing && block.content !== localContentRef.current) { + const acceptAfterUndo = Date.now() < acceptRemoteUntilRef.current; + if ((!isActivelyEditing || acceptAfterUndo) && block.content !== localContentRef.current) { setLocalContent(block.content || ''); localContentRef.current = block.content || ''; } @@ -406,7 +428,44 @@ export const OutlinerBlock: React.FC = ({ onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines); }; + // Ctrl/Cmd+Z undoes the user's own changes in the whole note (not the browser's per-field undo) + const runHistory = (redo: boolean) => { + if (debounceTimerRef.current !== null) { + flushDebounce(localContentRef.current); + } + acceptRemoteUntilRef.current = Date.now() + 500; + if (redo) onRedoRef.current?.(); + else onUndoRef.current?.(); + }; + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + // Mobile keyboards and the context menu undo via beforeinput instead of key events + const onBeforeInput = (e: InputEvent) => { + if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') { + e.preventDefault(); + runHistory(e.inputType === 'historyRedo'); + } + }; + el.addEventListener('beforeinput', onBeforeInput); + return () => el.removeEventListener('beforeinput', onBeforeInput); + }); + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) { + e.preventDefault(); + e.stopPropagation(); + runHistory(e.key.toLowerCase() === 'y' || e.shiftKey); + return; + } + // Enter in a block someone else is editing cannot split it (read-only), so it adds a new block below + // without touching the locked one. (Chromium does not move the caret with End in read-only fields.) + if (lockedBy && canAppendBelow && e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onCreateBlockBelowRef.current(block.id, ''); + return; + } if (!canEdit) return; // Slash command navigation @@ -577,7 +636,7 @@ export const OutlinerBlock: React.FC = ({ : isTargeted ? 'bg-panel-elevated' : '' - }`} + } ${block.conflictOf ? 'border-l-2 border-status-warn bg-status-warn/5' : ''}`} style={{ paddingLeft: `${visualLevel * 22 + 4}px`, }} @@ -585,7 +644,7 @@ export const OutlinerBlock: React.FC = ({ onDrop={() => onDropBlock?.(index)} onClick={(e) => { if ((e.target as HTMLElement).closest('button, a, input, select')) return; - if (!activeFocus && canEdit && textareaRef.current) { + if (!activeFocus && canFocus && textareaRef.current) { textareaRef.current.focus(); const len = textareaRef.current.value.length; textareaRef.current.setSelectionRange(len, len); @@ -728,7 +787,7 @@ export const OutlinerBlock: React.FC = ({ {/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
{ - if (!activeFocus && canEdit && textareaRef.current) { + if (!activeFocus && canFocus && textareaRef.current) { // Keep the row's click handler from moving the caret back to the end e.stopPropagation(); focusAtPoint(e.clientX, e.clientY); @@ -875,6 +934,37 @@ export const OutlinerBlock: React.FC = ({
)} + {/* Conflict copy: text that lost against someone else's change */} + {block.conflictOf && ( +
+ + + {block.conflictOf === 'title' ? 'Konflikt beim Titel' : 'Konflikt'} + {block.conflictAuthor ? ` – Version von ${block.conflictAuthor}` : ''} + + {onResolveConflict && ( + <> + + + + )} +
+ )} + {/* Rich Tokens Bar (Wiki-Links & #Tags) if present */} {hasSpecialTokens && !isFocused && (
@@ -980,6 +1070,17 @@ export const OutlinerBlock: React.FC = ({ + {/* Someone else is editing this block */} + {lockedBy && ( + + + {lockedBy} + + )} + {/* Delete button on hover (Trash2 icon) */} {canEdit && (