feat(notes): show block locks and presence, conflict copies with accept/discard, undo/redo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s
Phase 3 of the collaboration plan:
- Blocks held by someone else show a lock with the name and are read-only;
Enter in such a block adds a new block below. Delete, merge, indent and
drag are not started when a involved block (or one in its branch) is
locked; a short notice explains why (fixes FLAW-25).
- Presence: the editor header lists who else has the note open.
- Conflict copies are highlighted ("Konflikt – Version von …") with
Übernehmen (replaces the original or the title) and Verwerfen. Both check
the locks of the original and of the copy first.
- Undo/redo of own changes (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y, beforeinput
history events): typing is grouped per block until a 1 s pause, up to 200
steps per tab; a step is skipped with a notice if someone else changed or
holds the block meanwhile. New clearConflict props patch on the server.
- New blocks get focus synchronously on mount (fixes FLAW-26).
- Fix: after reopening a note from local storage the engine polled every
30 s instead of 3 s until the next change, because the delay was decided
before the first response told it the note is shared.
- Tests: undo/conflict engine tests, lock/undo/conflict UI tests and
e2e/lock.e2e.mjs (two browsers); all three browser tests pass against a
freshly built image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
08d9b2dc77
commit
aa397c18cd
20 files changed
+1049
-27
No files matched your search
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+4
-2
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+5
-1
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+1
-1
@@ -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'
|
||||
@@ -405,6 +405,50 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}
|
||||
}, [engineState?.status, engineState?.errorMessage]);
|
||||
|
||||
// Blocks other users are editing right now (blockId -> name)
|
||||
const lockedBlocks = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
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<string | null>(null);
|
||||
const noticeTimerRef = useRef<number | null>(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<DocumentEditorProps> = ({
|
||||
|
||||
{/* Right Action Buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Who else has this note open */}
|
||||
{otherPeople.length > 0 && (
|
||||
<span
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
||||
title="Hat diese Notiz gerade geöffnet"
|
||||
>
|
||||
<Users className="w-3 h-3 text-accent" />
|
||||
{otherPeople.map((p) => p.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{/* 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<DocumentEditorProps> = ({
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||
lockedBlocks={lockedBlocks}
|
||||
onLockedAction={handleLockedAction}
|
||||
onResolveConflict={handleResolveConflict}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1014,6 +1073,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
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<DocumentEditorProps> = ({
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||
lockedBlocks={lockedBlocks}
|
||||
onLockedAction={handleLockedAction}
|
||||
onResolveConflict={handleResolveConflict}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
|
||||
>
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<OutlinerBlockProps> = ({
|
||||
isTargeted,
|
||||
registerTextareaRef,
|
||||
onPasteMultiLines,
|
||||
lockedBy,
|
||||
canAppendBelow,
|
||||
onResolveConflict,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(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<string | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
@@ -164,6 +178,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
[]
|
||||
);
|
||||
|
||||
// Right after undo/redo the focused field must show the engine's text, not keep its own
|
||||
const acceptRemoteUntilRef = useRef<number>(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<OutlinerBlockProps> = ({
|
||||
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<OutlinerBlockProps> = ({
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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<OutlinerBlockProps> = ({
|
||||
: 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<OutlinerBlockProps> = ({
|
||||
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<OutlinerBlockProps> = ({
|
||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
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<OutlinerBlockProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conflict copy: text that lost against someone else's change */}
|
||||
{block.conflictOf && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
|
||||
<AlertTriangle className="w-3 h-3 shrink-0" />
|
||||
<span>
|
||||
{block.conflictOf === 'title' ? 'Konflikt beim Titel' : 'Konflikt'}
|
||||
{block.conflictAuthor ? ` – Version von ${block.conflictAuthor}` : ''}
|
||||
</span>
|
||||
{onResolveConflict && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResolveConflict(block.id, true)}
|
||||
className="px-1.5 py-0.5 rounded-btn border border-status-warn/40 hover:bg-status-warn/15 cursor-pointer"
|
||||
title={block.conflictOf === 'title' ? 'Diesen Text als Titel übernehmen' : 'Diesen Text in den ursprünglichen Punkt übernehmen'}
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResolveConflict(block.id, false)}
|
||||
className="px-1.5 py-0.5 rounded-btn border border-border text-muted hover:text-main hover:bg-panel cursor-pointer"
|
||||
title="Diese Version verwerfen"
|
||||
>
|
||||
Verwerfen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
|
||||
{hasSpecialTokens && !isFocused && (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
@@ -980,6 +1070,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Someone else is editing this block */}
|
||||
{lockedBy && (
|
||||
<span
|
||||
className="shrink-0 self-start mt-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-btn text-[10px] text-muted bg-panel border border-border select-none"
|
||||
title={`${lockedBy} bearbeitet diesen Punkt gerade`}
|
||||
>
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
{lockedBy}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Delete button on hover (Trash2 icon) */}
|
||||
{canEdit && (
|
||||
<button
|
||||
|
||||
@@ -24,6 +24,11 @@ export interface OutlinerTreeProps {
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
onFocusedBlockChange?: (blockId: string | null) => void;
|
||||
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
|
||||
onLockedAction?: (holderName: string) => void;
|
||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
}
|
||||
|
||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
@@ -43,6 +48,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onWikiLinkClick,
|
||||
initialFocusedBlockId,
|
||||
onFocusedBlockChange,
|
||||
lockedBlocks,
|
||||
onLockedAction,
|
||||
onResolveConflict,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}, ref) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
|
||||
@@ -125,21 +135,26 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
|
||||
const target = pendingFocusRef.current;
|
||||
pendingFocusRef.current = null;
|
||||
requestAnimationFrame(() => {
|
||||
// Focus right away (the element is already in the DOM): keystrokes typed directly after Enter
|
||||
// must land in the new block, not in the previous one
|
||||
setFocusedBlockId(blockId);
|
||||
el.focus();
|
||||
try {
|
||||
if (target.cursor === 'select') {
|
||||
el.select();
|
||||
} else {
|
||||
const pos = target.cursor === 'end' ? el.value.length : 0;
|
||||
const pos =
|
||||
typeof target.cursor === 'number'
|
||||
? Math.min(target.cursor, el.value.length)
|
||||
: target.cursor === 'end'
|
||||
? el.value.length
|
||||
: 0;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
textareaMapRef.current.delete(blockId);
|
||||
@@ -482,9 +497,33 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
|
||||
);
|
||||
|
||||
// Name of the other user holding the block (or, withBranch, any block below it); structural actions on
|
||||
// such blocks are not started at all, so they cannot end up half applied
|
||||
const lockHolder = useCallback(
|
||||
(blockId: string, withBranch = false): string | undefined => {
|
||||
if (!lockedBlocks || lockedBlocks.size === 0) return undefined;
|
||||
const direct = lockedBlocks.get(blockId);
|
||||
if (direct || !withBranch) return direct;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx < 0) return undefined;
|
||||
const level = blocks[idx].level || 0;
|
||||
for (let i = idx + 1; i < blocks.length && (blocks[i].level || 0) > level; i++) {
|
||||
const name = lockedBlocks.get(blocks[i].id);
|
||||
if (name) return name;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[blocks, lockedBlocks]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string, deleteSubtree = false) => {
|
||||
if (!canEdit) return;
|
||||
const holder = lockHolder(blockId, true);
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
return;
|
||||
}
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
@@ -526,7 +565,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onDeleteBlock(blockId);
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
// Backspace at the start of a non-empty block: append its text to the previous visible block
|
||||
@@ -540,6 +579,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
const cur = blocks[idx];
|
||||
if (!cur || notMergeable.includes(prev.type) || notMergeable.includes(cur.type)) return false;
|
||||
const holder = lockHolder(cur.id, true) || lockHolder(prev.id);
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Children of the merged block move up so they become children of the previous block
|
||||
const curLevel = Math.max(0, cur.level || 0);
|
||||
@@ -563,7 +607,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
focusBlockInput(prev.id, joinPos);
|
||||
return true;
|
||||
},
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, onUpdateBlocks, visibleEntries]
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, onUpdateBlocks, visibleEntries, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
const handleIndentBlock = useCallback(
|
||||
@@ -571,6 +615,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
const holder = lockHolder(blockId);
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = blocks[idx];
|
||||
const currentLevel = Math.max(0, current.level || 0);
|
||||
@@ -616,7 +665,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
}
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
|
||||
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
const handleNavigateFocus = useCallback(
|
||||
@@ -685,13 +734,16 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
return;
|
||||
}
|
||||
const movingBlock = blocks[draggedIndex];
|
||||
if (movingBlock) {
|
||||
const holder = movingBlock ? lockHolder(movingBlock.id) : undefined;
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
} else if (movingBlock) {
|
||||
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
},
|
||||
[blocks, draggedIndex, onMoveBlock]
|
||||
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -717,7 +769,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
canEdit={canEdit && !lockedBlocks?.has(block.id)}
|
||||
lockedBy={lockedBlocks?.get(block.id)}
|
||||
canAppendBelow={canEdit}
|
||||
onResolveConflict={canEdit ? onResolveConflict : undefined}
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
|
||||
import { DocumentBlock } from '../../../lib/types';
|
||||
@@ -62,3 +62,59 @@ describe('clicking into an unfocused bullet', () => {
|
||||
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
|
||||
});
|
||||
});
|
||||
|
||||
function renderBlock(block: DocumentBlock, extra: Record<string, unknown> = {}) {
|
||||
return render(
|
||||
<OutlinerBlock
|
||||
docId="d"
|
||||
block={block}
|
||||
index={0}
|
||||
isLast
|
||||
maxAllowedLevel={1}
|
||||
hasChildren={false}
|
||||
isFocused={false}
|
||||
canEdit
|
||||
onUpdateBlock={() => {}}
|
||||
onCreateBlockBelow={() => {}}
|
||||
onDeleteBlock={() => {}}
|
||||
onIndentBlock={() => {}}
|
||||
onNavigateFocus={() => {}}
|
||||
registerTextareaRef={() => {}}
|
||||
{...extra}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('undo shortcut', () => {
|
||||
it('saves pending typing first, then undoes', () => {
|
||||
const calls: string[] = [];
|
||||
const block = { id: 'b1', type: 'bullet', content: 'Alt', level: 0 } as DocumentBlock;
|
||||
const { container } = renderBlock(block, {
|
||||
onUpdateBlock: (b: DocumentBlock) => calls.push(`update:${b.content}`),
|
||||
onUndo: () => calls.push('undo'),
|
||||
onRedo: () => calls.push('redo'),
|
||||
});
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
fireEvent.change(ta, { target: { value: 'Alt neu' } });
|
||||
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true });
|
||||
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
fireEvent.keyDown(ta, { key: 'y', metaKey: true });
|
||||
expect(calls).toEqual(['update:Alt neu', 'undo', 'redo', 'redo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict copies', () => {
|
||||
it('show who lost and offer accept/discard', () => {
|
||||
const onResolve = vi.fn();
|
||||
const block = { id: 'cf', type: 'bullet', content: 'Meine Version', level: 0, conflictOf: 'b1', conflictAuthor: 'Ben' } as DocumentBlock;
|
||||
const { getByText } = renderBlock(block, { onResolveConflict: onResolve });
|
||||
expect(getByText(/Version von Ben/)).toBeTruthy();
|
||||
fireEvent.click(getByText('Übernehmen'));
|
||||
fireEvent.click(getByText('Verwerfen'));
|
||||
expect(onResolve.mock.calls).toEqual([
|
||||
['cf', true],
|
||||
['cf', false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,17 +7,26 @@ import { DocumentBlock } from '../../../lib/types';
|
||||
// Holds the latest block state rendered by the harness
|
||||
let live: DocumentBlock[] = [];
|
||||
|
||||
const Harness: React.FC<{ initial: DocumentBlock[] }> = ({ initial }) => {
|
||||
let lockedNotices: string[] = [];
|
||||
|
||||
const Harness: React.FC<{ initial: DocumentBlock[]; locked?: Map<string, string> }> = ({ initial, locked }) => {
|
||||
const [blocks, setBlocks] = useState(initial);
|
||||
live = blocks;
|
||||
return (
|
||||
<OutlinerTree
|
||||
docId="d1"
|
||||
blocks={blocks}
|
||||
lockedBlocks={locked}
|
||||
onLockedAction={(name) => lockedNotices.push(name)}
|
||||
canEdit
|
||||
onUpdateBlock={(b) => setBlocks((p) => p.map((x) => (x.id === b.id ? b : x)))}
|
||||
onUpdateBlocks={(u) => setBlocks((p) => p.map((x) => u.find((y) => y.id === x.id) || x))}
|
||||
onCreateBlock={() => {}}
|
||||
onCreateBlock={(nb, after) =>
|
||||
setBlocks((p) => {
|
||||
const i = p.findIndex((x) => x.id === after);
|
||||
return i < 0 ? [...p, nb] : [...p.slice(0, i + 1), nb, ...p.slice(i + 1)];
|
||||
})
|
||||
}
|
||||
onDeleteBlock={(id) => setBlocks((p) => p.filter((x) => x.id !== id))}
|
||||
onMoveBlock={() => {}}
|
||||
/>
|
||||
@@ -77,3 +86,46 @@ describe('Backspace at the start of a bullet', () => {
|
||||
expect(live).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocks locked by someone else', () => {
|
||||
it('are read-only and show who edits them', () => {
|
||||
render(<Harness initial={[mk('a', 'Frei'), mk('b', 'Gesperrt')]} locked={new Map([['b', 'Anna']])} />);
|
||||
expect(area('Gesperrt').readOnly).toBe(true);
|
||||
expect(area('Frei').readOnly).toBe(false);
|
||||
expect(screen.getByTitle('Anna bearbeitet diesen Punkt gerade')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('merging into a locked bullet is not started at all', async () => {
|
||||
lockedNotices = [];
|
||||
render(<Harness initial={[mk('a', 'Oben'), mk('b', 'Unten')]} locked={new Map([['a', 'Anna']])} />);
|
||||
await backspaceAt(area('Unten'));
|
||||
expect(live.map((b) => b.content)).toEqual(['Oben', 'Unten']);
|
||||
expect(lockedNotices).toEqual(['Anna']);
|
||||
});
|
||||
|
||||
it('a branch with a locked child cannot be merged away', async () => {
|
||||
lockedNotices = [];
|
||||
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} locked={new Map([['c', 'Ben']])} />);
|
||||
await backspaceAt(area('B'));
|
||||
expect(live.map((b) => b.content)).toEqual(['A', 'B', 'C']);
|
||||
expect(lockedNotices).toEqual(['Ben']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enter on a locked bullet', () => {
|
||||
for (const [label, pos] of [
|
||||
['at the end', 8],
|
||||
['in the middle', 3],
|
||||
] as const) {
|
||||
it(`${label} adds a new bullet below and never splits the locked one`, async () => {
|
||||
render(<Harness initial={[mk('a', 'Gesperrt')]} locked={new Map([['a', 'Anna']])} />);
|
||||
const ta = area('Gesperrt');
|
||||
ta.focus();
|
||||
ta.setSelectionRange(pos, pos);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||
});
|
||||
expect(live.map((b) => b.content)).toEqual(['Gesperrt', '']);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { DocSyncEngine, formBatch } from '../engine';
|
||||
import { applyOp, cloneDoc, Doc, emptyDoc, Op, Result } from '../model';
|
||||
@@ -254,6 +254,33 @@ describe('DocSyncEngine', () => {
|
||||
expect(serverContents(server)).toEqual(['Start']);
|
||||
});
|
||||
|
||||
it('polls every 3 s right after reopening a shared note from local storage', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
let calls = 0;
|
||||
const sync = net.sync.bind(net);
|
||||
net.sync = async (...args: Parameters<typeof sync>) => {
|
||||
calls++;
|
||||
return sync(...args);
|
||||
};
|
||||
// Opened before: the note is reopened from local storage (like after a reload)
|
||||
const storage = new MemoryStorage();
|
||||
const first = new DocSyncEngine('doc', { transport: net, storage, manual: true });
|
||||
await first.start();
|
||||
first.stop();
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage });
|
||||
await engine.start();
|
||||
const afterStart = calls;
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(calls).toBeGreaterThan(afterStart); // not only after 30 s
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('formBatch keeps the last text per block with the base of the first', () => {
|
||||
const batch = formBatch([
|
||||
{ opId: '1', kind: 'set_content', blockId: 'a', content: 'x', baseVersion: 5 },
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { DocSyncEngine } from '../engine';
|
||||
import { applyOp, Doc, emptyDoc, Op, Result } from '../model';
|
||||
import { MemoryStorage } from '../persist';
|
||||
import { LogEntry, SyncRequest, Transport, TransportError } from '../transport';
|
||||
|
||||
/** Minimal stand-in server (same rules as the Go server, plus configurable leases of others). */
|
||||
class Server {
|
||||
doc: Doc = emptyDoc();
|
||||
log: LogEntry[] = [];
|
||||
results = new Map<string, Result>();
|
||||
othersLeases: { blockId: string; userId: string; name: string }[] = [];
|
||||
|
||||
constructor(blocks: [string, string][]) {
|
||||
blocks.forEach(([id, content], i) => this.apply({ opId: `seed${i}`, kind: 'insert', blockId: id, parentId: '', orderKey: `a${i}`, content }, 'seed'));
|
||||
}
|
||||
apply(op: Op, by: string): Result {
|
||||
const known = this.results.get(op.opId);
|
||||
if (known) return known;
|
||||
const { effects, result } = applyOp(this.doc, op, by);
|
||||
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: by, clientId: by, opId: op.opId, effect: e });
|
||||
if (effects.length) result.seq = this.log.length;
|
||||
this.results.set(op.opId, result);
|
||||
return result;
|
||||
}
|
||||
transport(): Transport {
|
||||
return {
|
||||
snapshot: async () => ({
|
||||
docId: 'd',
|
||||
title: this.doc.title,
|
||||
titleVersion: this.doc.titleVersion,
|
||||
headSeq: this.log.length,
|
||||
blocks: Array.from(this.doc.blocks.values()).map((b) => ({ ...b, props: { ...b.props } })),
|
||||
role: 'owner',
|
||||
shared: true,
|
||||
}),
|
||||
sync: async (_id: string, body: SyncRequest) => {
|
||||
if (!body) throw new TransportError('x', 0);
|
||||
const results = body.ops.map((op) => this.apply(op, body.clientId));
|
||||
return {
|
||||
results,
|
||||
ops: this.log.filter((e) => e.seq > body.lastSeq),
|
||||
headSeq: this.log.length,
|
||||
hasMore: false,
|
||||
role: 'owner',
|
||||
shared: true,
|
||||
sessions: 2,
|
||||
presence: [],
|
||||
leases: { granted: [], denied: [], others: this.othersLeases },
|
||||
};
|
||||
},
|
||||
leave: async () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let clock = 1_000_000;
|
||||
async function engineFor(server: Server) {
|
||||
const e = new DocSyncEngine('d', { transport: server.transport(), storage: new MemoryStorage(), manual: true, now: () => clock });
|
||||
await e.start();
|
||||
return e;
|
||||
}
|
||||
const flat = (e: DocSyncEngine) => e.getState().blocks;
|
||||
const texts = (e: DocSyncEngine) => flat(e).map((b) => b.content);
|
||||
function edit(e: DocSyncEngine, fn: (b: DocumentBlock[]) => DocumentBlock[]) {
|
||||
e.applyFlat(fn(flat(e).map((b) => ({ ...b }))));
|
||||
}
|
||||
|
||||
describe('undo/redo', () => {
|
||||
it('groups typing in one block and reverts it in one step', async () => {
|
||||
const server = new Server([['a', 'Hallo']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Hallo W' }]);
|
||||
clock += 300;
|
||||
edit(e, (b) => [{ ...b[0], content: 'Hallo Welt' }]);
|
||||
expect(e.undo().ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Hallo']);
|
||||
expect(e.getState().canUndo).toBe(false);
|
||||
expect(e.redo().ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Hallo Welt']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.content).toBe('Hallo Welt');
|
||||
});
|
||||
|
||||
it('separates typing steps after a pause', async () => {
|
||||
const server = new Server([['a', '']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Eins' }]);
|
||||
clock += 2000;
|
||||
edit(e, (b) => [{ ...b[0], content: 'Eins Zwei' }]);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Eins']);
|
||||
});
|
||||
|
||||
it('undoes inserting and deleting blocks', async () => {
|
||||
const server = new Server([['a', 'Erster']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [...b, { id: 'n', type: 'bullet', content: 'Neu', level: 0 }]);
|
||||
clock += 2000;
|
||||
edit(e, (b) => b.filter((x) => x.id !== 'a'));
|
||||
expect(texts(e)).toEqual(['Neu']);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Erster', 'Neu']);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Erster']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.deleted).toBe(false);
|
||||
expect(server.doc.blocks.get('n')!.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('skips undo when someone else changed the block in between', async () => {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
|
||||
await e.syncNow();
|
||||
server.apply({ opId: 'other', kind: 'set_content', blockId: 'a', baseVersion: 2, content: 'Fremd' }, 'other');
|
||||
await e.syncNow();
|
||||
const res = e.undo();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/jemand anderem/);
|
||||
expect(texts(e)).toEqual(['Fremd']);
|
||||
});
|
||||
|
||||
it('skips undo on a block someone else is editing', async () => {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
|
||||
await e.syncNow();
|
||||
server.othersLeases = [{ blockId: 'a', userId: 'u2', name: 'Anna' }];
|
||||
await e.syncNow();
|
||||
const res = e.undo();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/Anna/);
|
||||
expect(texts(e)).toEqual(['Meins']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict copies', () => {
|
||||
async function withConflict() {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
server.apply({ opId: 'first', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Gewinner' }, 'other');
|
||||
server.apply({ opId: 'second', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Verlierer' }, 'mine');
|
||||
await e.syncNow();
|
||||
const copy = flat(e).find((b) => b.conflictOf === 'a')!;
|
||||
return { server, e, copy };
|
||||
}
|
||||
|
||||
it('accepting replaces the original text and removes the copy', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
expect(e.resolveConflict(copy.id, true).ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Verlierer']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.content).toBe('Verlierer');
|
||||
expect(server.doc.blocks.get(copy.id)!.deleted).toBe(true);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
|
||||
});
|
||||
|
||||
it('is not started while someone else holds the copy', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
server.othersLeases = [{ blockId: copy.id, userId: 'u2', name: 'Ben' }];
|
||||
await e.syncNow();
|
||||
const res = e.resolveConflict(copy.id, true);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/Ben/);
|
||||
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
|
||||
});
|
||||
|
||||
it('discarding removes only the copy', async () => {
|
||||
const { e, copy } = await withConflict();
|
||||
e.resolveConflict(copy.id, false);
|
||||
expect(texts(e)).toEqual(['Gewinner']);
|
||||
});
|
||||
|
||||
it('a copy whose original is gone becomes a normal block', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
server.apply({ opId: 'del', kind: 'delete', blockId: 'a' }, 'other');
|
||||
await e.syncNow();
|
||||
e.resolveConflict(copy.id, true);
|
||||
expect(flat(e).find((b) => b.id === copy.id)?.conflictOf).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { diffFlat, toDocumentBlocks } from './diff';
|
||||
import { applyEffect, applyOp, cloneDoc, Doc, docFromSnapshot, emptyDoc, Op, Result } from './model';
|
||||
import { defaultStorage, SyncStorage } from './persist';
|
||||
import { httpTransport, LeaseInfo, Presence, Transport, TransportError } from './transport';
|
||||
import { buildStep, failedChecks, touchedBlocks, UndoStep } from './undo';
|
||||
|
||||
export type EngineStatus = 'loading' | 'saved' | 'saving' | 'offline' | 'error';
|
||||
|
||||
@@ -23,6 +24,13 @@ export interface EngineState {
|
||||
presence: Presence[];
|
||||
lockedByOthers: LeaseInfo[];
|
||||
conflicts: Result[]; // conflicts of own operations since start
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface EngineOptions {
|
||||
@@ -39,6 +47,8 @@ const SYNC_DEBOUNCE_MS = 300;
|
||||
const POLL_COLLAB_MS = 3_000;
|
||||
const POLL_SOLO_MS = 30_000;
|
||||
const BACKOFF_MAX_MS = 30_000;
|
||||
const UNDO_LIMIT = 200;
|
||||
const UNDO_GROUP_MS = 1_000; // keystrokes in the same block are one undo step until a pause this long
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
@@ -118,6 +128,8 @@ export class DocSyncEngine {
|
||||
// Version of each block the editor last showed in agreement with the engine. The focused text field does
|
||||
// not take over remote changes while typing, so its edits are based on this version, not the newest one.
|
||||
private uiSeen = new Map<string, number>();
|
||||
private undoStack: UndoStep[] = [];
|
||||
private redoStack: UndoStep[] = [];
|
||||
|
||||
private syncing = false;
|
||||
private syncAgain = false;
|
||||
@@ -198,6 +210,8 @@ export class DocSyncEngine {
|
||||
presence: this.presence,
|
||||
lockedByOthers: this.lockedByOthers,
|
||||
conflicts: this.conflicts,
|
||||
canUndo: this.undoStack.length > 0,
|
||||
canRedo: this.redoStack.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -223,12 +237,14 @@ export class DocSyncEngine {
|
||||
}
|
||||
const ops = diffFlat(this.local, blocks, () => this.newOpId());
|
||||
if (ops.length === 0) return;
|
||||
const before = this.local;
|
||||
const t = this.now();
|
||||
for (const op of ops) {
|
||||
if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t);
|
||||
if (op.kind === 'set_content' && this.uiSeen.has(op.blockId!)) op.baseVersion = this.uiSeen.get(op.blockId!);
|
||||
}
|
||||
this.enqueue(ops);
|
||||
this.record(before, ops);
|
||||
// The editor now shows exactly the local state for these blocks
|
||||
for (const op of ops) {
|
||||
if (op.kind === 'set_content' || op.kind === 'insert') {
|
||||
@@ -241,7 +257,58 @@ export class DocSyncEngine {
|
||||
setTitle(title: string): void {
|
||||
if (!this.ready || !this.canEdit || title === this.local.title) return;
|
||||
// The title field always shows the engine's title, so the current local version is what the user saw
|
||||
this.enqueue([{ opId: this.newOpId(), kind: 'set_title', title, baseVersion: this.local.titleVersion }]);
|
||||
const before = this.local;
|
||||
const ops: Op[] = [{ opId: this.newOpId(), kind: 'set_title', title, baseVersion: this.local.titleVersion }];
|
||||
this.enqueue(ops);
|
||||
this.record(before, ops);
|
||||
}
|
||||
|
||||
/** Name of the user holding the lease on a block, if it is someone else. */
|
||||
lockedBy(blockId: string): string | undefined {
|
||||
return this.lockedByOthers.find((l) => l.blockId === blockId)?.name;
|
||||
}
|
||||
|
||||
/** Reverts the user's last own change (skipped if someone else changed the blocks since). */
|
||||
undo(): ActionResult {
|
||||
return this.replay(this.undoStack, this.redoStack, 'Rückgängig');
|
||||
}
|
||||
|
||||
/** Re-applies the last undone change. */
|
||||
redo(): ActionResult {
|
||||
return this.replay(this.redoStack, this.undoStack, 'Wiederholen');
|
||||
}
|
||||
|
||||
/** Accepts (replaces the original with the conflict copy) or discards a conflict copy. */
|
||||
resolveConflict(copyId: string, accept: boolean): ActionResult {
|
||||
if (!this.ready || !this.canEdit) return { ok: false };
|
||||
const copy = this.local.blocks.get(copyId);
|
||||
if (!copy || copy.deleted || !copy.props.conflictOf) return { ok: false };
|
||||
const target = copy.props.conflictOf;
|
||||
// The author of the conflicting text holds the copy for a moment; deleting it now would fail half-way
|
||||
const copyHolder = this.lockedBy(copyId);
|
||||
if (copyHolder) return { ok: false, message: `${copyHolder} bearbeitet diese Version gerade – bitte kurz warten` };
|
||||
const ops: Op[] = [];
|
||||
if (!accept) {
|
||||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||||
} else if (target === 'title') {
|
||||
ops.push({ opId: this.newOpId(), kind: 'set_title', title: copy.content, baseVersion: this.local.titleVersion });
|
||||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||||
} else {
|
||||
const original = this.local.blocks.get(target);
|
||||
if (original && !original.deleted) {
|
||||
const holder = this.lockedBy(target);
|
||||
if (holder) return { ok: false, message: `Der ursprüngliche Punkt wird gerade von ${holder} bearbeitet` };
|
||||
ops.push({ opId: this.newOpId(), kind: 'set_content', blockId: target, content: copy.content, baseVersion: original.version });
|
||||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||||
} else {
|
||||
// The original is gone: the copy simply becomes a normal block
|
||||
ops.push({ opId: this.newOpId(), kind: 'set_props', blockId: copyId, patch: { clearConflict: true } });
|
||||
}
|
||||
}
|
||||
const before = this.local;
|
||||
this.enqueue(ops);
|
||||
this.record(before, ops);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Tells the engine which block the cursor is in (null = none); used for block leases. */
|
||||
@@ -264,11 +331,12 @@ export class DocSyncEngine {
|
||||
this.timer = null;
|
||||
}
|
||||
this.syncing = true;
|
||||
let delay = this.pollDelay();
|
||||
let delay: number;
|
||||
try {
|
||||
await this.roundTrip();
|
||||
this.backoff = 0;
|
||||
if (this.syncAgain || this.pending.length > 0) delay = 0;
|
||||
// Decided only now: the response tells whether the note is shared (3 s) or private (30 s)
|
||||
delay = this.syncAgain || this.pending.length > 0 ? 0 : this.pollDelay();
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
delay = this.status === 'error' ? this.pollDelay() : this.nextBackoff();
|
||||
@@ -297,6 +365,56 @@ export class DocSyncEngine {
|
||||
void this.persist().finally(() => this.schedule(SYNC_DEBOUNCE_MS));
|
||||
}
|
||||
|
||||
private record(before: Doc, ops: Op[]): void {
|
||||
const step = buildStep(before, ops, this.now());
|
||||
if (!step) return;
|
||||
this.redoStack = [];
|
||||
const top = this.undoStack[this.undoStack.length - 1];
|
||||
if (step.textKey && top && top.textKey === step.textKey && step.at - top.at < UNDO_GROUP_MS) {
|
||||
// Same block, still typing: keep the original text as undo target, update the expected result
|
||||
top.checks = step.checks;
|
||||
top.at = step.at;
|
||||
} else {
|
||||
this.undoStack.push(step);
|
||||
if (this.undoStack.length > UNDO_LIMIT) this.undoStack.shift();
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private replay(from: UndoStep[], to: UndoStep[], label: string): ActionResult {
|
||||
if (!this.ready || !this.canEdit) return { ok: false };
|
||||
const step = from.pop();
|
||||
if (!step) return { ok: false };
|
||||
if (failedChecks(this.local, step).length > 0) {
|
||||
this.emit();
|
||||
return { ok: false, message: `${label} übersprungen: Der Punkt wurde inzwischen von jemand anderem geändert` };
|
||||
}
|
||||
const holder = touchedBlocks(step).map((id) => this.lockedBy(id)).find(Boolean);
|
||||
if (holder) {
|
||||
this.emit();
|
||||
return { ok: false, message: `${label} übersprungen: ${holder} bearbeitet den Punkt gerade` };
|
||||
}
|
||||
const ops: Op[] = step.inverse.map((o) => {
|
||||
const op: Op = { ...o, opId: this.newOpId() } as Op;
|
||||
if (op.kind === 'set_content') op.baseVersion = this.local.blocks.get(op.blockId!)?.version ?? 0;
|
||||
if (op.kind === 'set_title') op.baseVersion = this.local.titleVersion;
|
||||
return op;
|
||||
});
|
||||
const before = this.local;
|
||||
this.enqueue(ops);
|
||||
const back = buildStep(before, ops, this.now());
|
||||
if (back) {
|
||||
back.textKey = undefined;
|
||||
to.push(back);
|
||||
}
|
||||
for (const op of ops) {
|
||||
const b = op.blockId ? this.local.blocks.get(op.blockId) : undefined;
|
||||
if (b) this.uiSeen.set(b.id, b.version);
|
||||
}
|
||||
this.emit();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private recompute(): void {
|
||||
const v = cloneDoc(this.confirmed);
|
||||
for (const op of [...(this.inflight || []), ...this.pending]) {
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface PropsPatch {
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
lang?: string;
|
||||
clearConflict?: boolean;
|
||||
}
|
||||
|
||||
export type OpKind = 'insert' | 'set_content' | 'set_props' | 'move' | 'delete' | 'restore' | 'set_title';
|
||||
@@ -345,6 +346,7 @@ export function applyOp(d: Doc, op: Op, by = '', conflictId: (opId: string) => s
|
||||
...(op.patch.checked !== undefined ? { checked: op.patch.checked } : {}),
|
||||
...(op.patch.collapsed !== undefined ? { collapsed: op.patch.collapsed } : {}),
|
||||
...(op.patch.lang !== undefined ? { lang: op.patch.lang } : {}),
|
||||
...(op.patch.clearConflict ? { conflictOf: undefined, conflictAuthor: undefined } : {}),
|
||||
});
|
||||
if (type !== b.type || !propsEqual(props, b.props)) emit({ kind: 'set_props', blockId: b.id, type, props });
|
||||
return done({ status: 'applied' });
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Undo/redo of the user's own changes. For every action the engine records the inverse operations plus a
|
||||
// check of the state the action left behind. Undo only runs if that state is still there, i.e. nobody
|
||||
// else changed the affected blocks in between; otherwise the step is skipped instead of overwriting.
|
||||
import { applyOp, cloneDoc, Doc, normProps, Op, propsEqual } from './model';
|
||||
|
||||
export type UndoCheck =
|
||||
| { kind: 'content'; blockId: string; content: string }
|
||||
| { kind: 'alive'; blockId: string; content: string }
|
||||
| { kind: 'deleted'; ids: string[] }
|
||||
| { kind: 'place'; blockId: string; parentId: string; orderKey: string; restoreParent: string }
|
||||
| { kind: 'props'; blockId: string; type: string; checked: boolean; collapsed: boolean; lang: string }
|
||||
| { kind: 'title'; title: string };
|
||||
|
||||
export interface UndoStep {
|
||||
inverse: Omit<Op, 'opId'>[]; // already in the order to apply
|
||||
checks: UndoCheck[];
|
||||
at: number;
|
||||
textKey?: string; // set for pure typing steps (block ID or '#title'), used to group keystrokes
|
||||
}
|
||||
|
||||
/** Applies ops to a copy of `before` and records how to revert them. */
|
||||
export function buildStep(before: Doc, ops: Op[], at: number): UndoStep | null {
|
||||
const v = cloneDoc(before);
|
||||
const inverse: Omit<Op, 'opId'>[] = [];
|
||||
const checks: UndoCheck[] = [];
|
||||
|
||||
for (const raw of ops) {
|
||||
const op = { ...raw };
|
||||
const pre = op.blockId ? v.blocks.get(op.blockId) : undefined;
|
||||
const preCopy = pre ? { ...pre, props: { ...pre.props } } : undefined;
|
||||
const preTitle = v.title;
|
||||
if (op.kind === 'set_content') op.baseVersion = pre?.version ?? 0;
|
||||
if (op.kind === 'set_title') op.baseVersion = v.titleVersion;
|
||||
const { effects } = applyOp(v, op);
|
||||
if (effects.length === 0) continue;
|
||||
const post = op.blockId ? v.blocks.get(op.blockId) : undefined;
|
||||
|
||||
switch (op.kind) {
|
||||
case 'insert':
|
||||
if (post && !post.deleted) {
|
||||
inverse.push({ kind: 'delete', blockId: post.id });
|
||||
checks.push({ kind: 'alive', blockId: post.id, content: post.content });
|
||||
}
|
||||
break;
|
||||
case 'set_content':
|
||||
if (preCopy && post) {
|
||||
inverse.push({ kind: 'set_content', blockId: post.id, content: preCopy.content });
|
||||
checks.push({ kind: 'content', blockId: post.id, content: post.content });
|
||||
}
|
||||
break;
|
||||
case 'set_props':
|
||||
if (preCopy && post) {
|
||||
const p = normProps(preCopy.props);
|
||||
inverse.push({
|
||||
kind: 'set_props',
|
||||
blockId: post.id,
|
||||
patch: { type: preCopy.type, checked: !!p.checked, collapsed: !!p.collapsed, lang: p.lang || '' },
|
||||
});
|
||||
const q = normProps(post.props);
|
||||
checks.push({ kind: 'props', blockId: post.id, type: post.type, checked: !!q.checked, collapsed: !!q.collapsed, lang: q.lang || '' });
|
||||
}
|
||||
break;
|
||||
case 'move':
|
||||
if (preCopy && post) {
|
||||
inverse.push({ kind: 'move', blockId: post.id, parentId: preCopy.parentId, orderKey: preCopy.orderKey });
|
||||
checks.push({ kind: 'place', blockId: post.id, parentId: post.parentId, orderKey: post.orderKey, restoreParent: preCopy.parentId });
|
||||
}
|
||||
break;
|
||||
case 'delete': {
|
||||
const ids = effects.flatMap((e) => (e.kind === 'delete' ? e.ids || [] : []));
|
||||
if (ids.length > 0) {
|
||||
inverse.push({ kind: 'restore', ids });
|
||||
checks.push({ kind: 'deleted', ids });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'restore': {
|
||||
const ids = effects.flatMap((e) => (e.kind === 'restore' ? e.ids || [] : []));
|
||||
for (const id of ids) inverse.push({ kind: 'delete', blockId: id });
|
||||
for (const id of ids) checks.push({ kind: 'alive', blockId: id, content: v.blocks.get(id)?.content || '' });
|
||||
break;
|
||||
}
|
||||
case 'set_title':
|
||||
inverse.push({ kind: 'set_title', title: preTitle });
|
||||
checks.push({ kind: 'title', title: v.title });
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (inverse.length === 0) return null;
|
||||
inverse.reverse();
|
||||
const onlyText = ops.every((o) => o.kind === 'set_content') && new Set(ops.map((o) => o.blockId)).size === 1;
|
||||
const onlyTitle = ops.every((o) => o.kind === 'set_title');
|
||||
return { inverse, checks, at, textKey: onlyText ? ops[0].blockId : onlyTitle ? '#title' : undefined };
|
||||
}
|
||||
|
||||
/** Returns the IDs of blocks the step touches whose state no longer matches, or [] if undo is safe. */
|
||||
export function failedChecks(d: Doc, step: UndoStep): string[] {
|
||||
const failed: string[] = [];
|
||||
for (const c of step.checks) {
|
||||
switch (c.kind) {
|
||||
case 'content': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (!b || b.deleted || b.content !== c.content) failed.push(c.blockId);
|
||||
break;
|
||||
}
|
||||
case 'alive': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (!b || b.deleted || b.content !== c.content) failed.push(c.blockId);
|
||||
break;
|
||||
}
|
||||
case 'deleted':
|
||||
for (const id of c.ids) if (!d.blocks.get(id)?.deleted) failed.push(id);
|
||||
break;
|
||||
case 'place': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
const parent = c.restoreParent ? d.blocks.get(c.restoreParent) : null;
|
||||
if (!b || b.deleted || b.parentId !== c.parentId || b.orderKey !== c.orderKey || (c.restoreParent && (!parent || parent.deleted))) {
|
||||
failed.push(c.blockId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'props': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (
|
||||
!b ||
|
||||
b.deleted ||
|
||||
b.type !== c.type ||
|
||||
!propsEqual(normProps({ checked: b.props.checked, collapsed: b.props.collapsed, lang: b.props.lang }), normProps({ checked: c.checked, collapsed: c.collapsed, lang: c.lang }))
|
||||
) {
|
||||
failed.push(c.blockId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'title':
|
||||
if (d.title !== c.title) failed.push('#title');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** Block IDs an inverse step would modify (for lease checks). */
|
||||
export function touchedBlocks(step: UndoStep): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const op of step.inverse) {
|
||||
if (op.blockId) ids.add(op.blockId);
|
||||
for (const id of op.ids || []) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
@@ -29,6 +29,8 @@ type PropsPatch struct {
|
||||
Checked *bool `json:"checked,omitempty"`
|
||||
Collapsed *bool `json:"collapsed,omitempty"`
|
||||
Lang *string `json:"lang,omitempty"`
|
||||
// ClearConflict removes the conflict marking (after the user accepted or kept a conflict copy)
|
||||
ClearConflict *bool `json:"clearConflict,omitempty"`
|
||||
}
|
||||
|
||||
// Op is a change requested by a client. OpID is chosen by the client and makes retries idempotent.
|
||||
@@ -262,6 +264,9 @@ func (ap *applier) apply(op Op) Result {
|
||||
if p.Lang != nil {
|
||||
props.Lang = *p.Lang
|
||||
}
|
||||
if p.ClearConflict != nil && *p.ClearConflict {
|
||||
props.ConflictOf, props.ConflictAuthor = "", ""
|
||||
}
|
||||
}
|
||||
if typ != b.Type || props != b.Props {
|
||||
ap.emit(Effect{Kind: OpSetProps, BlockID: b.ID, Type: typ, Props: &props})
|
||||
|
||||
@@ -261,3 +261,14 @@ func TestNotifier(t *testing.T) {
|
||||
t.Fatal("Notify must wake waiters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearConflictMarking(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Start"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("A")}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("B")}, ben, Env{})
|
||||
yes := true
|
||||
Apply(d, Op{OpID: "3", Kind: OpSetProps, BlockID: res.ConflictBlockID, Patch: &PropsPatch{ClearConflict: &yes}}, ben, Env{})
|
||||
if p := d.Blocks[res.ConflictBlockID].Props; p.ConflictOf != "" || p.ConflictAuthor != "" {
|
||||
t.Fatalf("conflict marking not cleared: %+v", p)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user