edytor 0.1.0-next.24 → 0.1.0-next.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cloudflare/DocumentRoom.d.ts +22 -0
- package/dist/cloudflare/DocumentRoom.js +75 -14
- package/dist/crdt/data.js +2 -1
- package/dist/crdt/document.d.ts +151 -0
- package/dist/crdt/document.js +11 -146
- package/dist/crdt/edytor-doc.d.ts +117 -4
- package/dist/crdt/edytor-doc.js +2 -0
- package/dist/crdt/engine.d.ts +3 -1
- package/dist/crdt/engine.js +4 -1
- package/dist/crdt/index.d.ts +21 -4
- package/dist/crdt/index.js +25 -1
- package/dist/crdt/migration/generation.d.ts +58 -0
- package/dist/crdt/migration/generation.js +68 -0
- package/dist/crdt/migration/migrate.js +1 -1
- package/dist/crdt/placement/model.d.ts +32 -0
- package/dist/crdt/placement/model.js +151 -7
- package/dist/crdt/placement/rank.d.ts +38 -24
- package/dist/crdt/placement/rank.js +147 -93
- package/dist/crdt/protocols/envelope.d.ts +9 -3
- package/dist/crdt/protocols/envelope.js +9 -3
- package/dist/crdt/protocols/sync.d.ts +13 -6
- package/dist/crdt/protocols/sync.js +31 -9
- package/dist/crdt/providers/index.js +2 -1
- package/dist/crdt/providers/indexeddb.d.ts +10 -0
- package/dist/crdt/providers/indexeddb.js +53 -6
- package/dist/crdt/providers/room.js +16 -1
- package/dist/crdt/schema.d.ts +1 -1
- package/dist/crdt/schema.js +4 -1
- package/dist/crdt/semantics.d.ts +46 -1
- package/dist/crdt/semantics.js +32 -3
- package/dist/crdt/text/deletes.js +102 -18
- package/dist/crdt/text/marks.d.ts +58 -0
- package/dist/crdt/text/marks.js +85 -0
- package/dist/crdt/text/model.js +15 -8
- package/dist/crdt/text/runs.js +48 -3
- package/dist/crdt/vendor/yjs/UPSTREAM.md +40 -0
- package/dist/crdt/vendor/yjs/dts/utils/Doc.d.ts +6 -0
- package/dist/crdt/vendor/yjs/dts/utils/marks.d.ts +83 -0
- package/dist/crdt/vendor/yjs/dts/utils/transaction-helpers.d.ts +1 -1
- package/dist/crdt/vendor/yjs/dts/ynode.d.ts +25 -0
- package/dist/crdt/vendor/yjs/src/structs/Item.js +12 -2
- package/dist/crdt/vendor/yjs/src/utils/Doc.js +6 -0
- package/dist/crdt/vendor/yjs/src/utils/RangeCursor.js +2 -0
- package/dist/crdt/vendor/yjs/src/utils/marks.js +149 -0
- package/dist/crdt/vendor/yjs/src/utils/transaction-helpers.js +10 -4
- package/dist/crdt/vendor/yjs/src/ynode.js +226 -2
- package/dist/edytor.svelte.js +19 -2
- package/dist/plugins/richtext/RichTextPlugin.svelte +2 -2
- package/dist/session/editing/text.d.ts +6 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
|
|
17
17
|
|
|
18
|
-
> **Work in progress.** Edytor is a pre-release (`0.1.0-next.
|
|
18
|
+
> **Work in progress.** Edytor is a pre-release (`0.1.0-next.25`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
|
|
19
19
|
|
|
20
20
|
## Features
|
|
21
21
|
|
|
@@ -317,6 +317,12 @@ export type RoomLogEntry = {
|
|
|
317
317
|
edytor: 'fault';
|
|
318
318
|
reason: 'storage' | 'internal';
|
|
319
319
|
detail: string;
|
|
320
|
+
} | {
|
|
321
|
+
edytor: 'convert';
|
|
322
|
+
from: number;
|
|
323
|
+
to: number;
|
|
324
|
+
blocks: number;
|
|
325
|
+
bytes: number;
|
|
320
326
|
};
|
|
321
327
|
/** `pending`: deletes of items the room does not hold yet (they wait for them). */
|
|
322
328
|
type RowKind = 'generation' | 'update' | 'snapshot' | 'pending';
|
|
@@ -481,6 +487,10 @@ export declare class AttachedDocument {
|
|
|
481
487
|
} | {
|
|
482
488
|
kind: 'restored';
|
|
483
489
|
records: number;
|
|
490
|
+
} | {
|
|
491
|
+
kind: 'converted';
|
|
492
|
+
from: number;
|
|
493
|
+
records: number;
|
|
484
494
|
};
|
|
485
495
|
private nextRecord;
|
|
486
496
|
private updates;
|
|
@@ -651,6 +661,18 @@ export declare class AttachedDocument {
|
|
|
651
661
|
* engine refuses) is refused for good.
|
|
652
662
|
*/
|
|
653
663
|
private load;
|
|
664
|
+
/**
|
|
665
|
+
* The generation cutover at load (`room.generation.convert`): the
|
|
666
|
+
* records of a generation-4 container are read as JSON
|
|
667
|
+
* (`crdt.generations.previousJSON`), which this generation seeds as
|
|
668
|
+
* `onLoad`'s JSON is (deterministic: every replica converting the same
|
|
669
|
+
* state writes the same update), and the container is replaced by it in
|
|
670
|
+
* one storage transaction: rows, replicas, purge epochs, slot editors
|
|
671
|
+
* and the stored restore go; the document data, the alarm's due tasks
|
|
672
|
+
* and `lastUpdated` stay. What a generation-4 client never sent is not
|
|
673
|
+
* in it; history, attribution and CRDT identity do not cross.
|
|
674
|
+
*/
|
|
675
|
+
private convert;
|
|
654
676
|
/** The bytes of the stored `pending` records (uncompressed, as every record but a snapshot). */
|
|
655
677
|
private pendingBytes;
|
|
656
678
|
/**
|
|
@@ -264,17 +264,7 @@ const knob = (value, fallback, max = fallback) => {
|
|
|
264
264
|
return Number.isInteger(n) && n > 0 ? Math.min(n, max) : fallback;
|
|
265
265
|
};
|
|
266
266
|
/** A semantics config as the facade's lookups — own keys only: block types come off the wire. */
|
|
267
|
-
const lookups = (semantics) =>
|
|
268
|
-
const own = (table = {}) => (type) => Object.hasOwn(table, type) ? table[type] : undefined;
|
|
269
|
-
const rendersContent = own(semantics.rendersContent);
|
|
270
|
-
return {
|
|
271
|
-
roleOf: own(semantics.roles),
|
|
272
|
-
kinds: () => Object.keys(semantics.roles ?? {}),
|
|
273
|
-
defaultChildOf: own(semantics.defaultChild),
|
|
274
|
-
rendersContent: (type) => rendersContent(type) ?? true,
|
|
275
|
-
defaultType: semantics.defaultType
|
|
276
|
-
};
|
|
277
|
-
};
|
|
267
|
+
const lookups = (semantics) => E.facadeConfigOf(semantics);
|
|
278
268
|
const encodeJSON = (value) => new TextEncoder().encode(JSON.stringify(value));
|
|
279
269
|
/** An awareness frame carrying `entries` — no `Awareness` instance involved. */
|
|
280
270
|
export const presenceFrame = (entries) => E.frame(E.messageAwareness, (e) => E.writeVarUint8Array(e, E.writeAwarenessEntries(entries)));
|
|
@@ -652,6 +642,8 @@ const roomDoc = (keep) => {
|
|
|
652
642
|
return doc;
|
|
653
643
|
};
|
|
654
644
|
/** Admit stored or loaded updates into a room document (`prepareRoomDoc`'s collection rules). */
|
|
645
|
+
/** How many blocks `children` holds, nested ones included. */
|
|
646
|
+
const countBlocks = (children) => children.reduce((n, block) => n + 1 + countBlocks(block.children ?? []), 0);
|
|
655
647
|
const admit = (updates, name, keep) => crdt.admission.admitUpdate(updates, name, { prepare: (doc) => prepareRoomDoc(doc, keep) });
|
|
656
648
|
// ── Per-writer marks (H2) ──────────────────────────────────────────
|
|
657
649
|
/** The writer `n` of a per-writer block mark key (`del.<n>`, `wd.<n>`), or `null`. */
|
|
@@ -1400,6 +1392,10 @@ export class AttachedDocument {
|
|
|
1400
1392
|
}
|
|
1401
1393
|
const [generation, ...rest] = records;
|
|
1402
1394
|
const found = JSON.parse(new TextDecoder().decode(generation.bytes));
|
|
1395
|
+
// A container of generation 4 converts through its JSON (`room.generation.convert`).
|
|
1396
|
+
if (generation.kind === 'generation' && E.isPreviousGenerationRecord(found)) {
|
|
1397
|
+
return this.convert(rest);
|
|
1398
|
+
}
|
|
1403
1399
|
if (generation.kind !== 'generation' || !isGenerationRecord(found)) {
|
|
1404
1400
|
throw new E.GenerationMismatchError(`room ${this.ctx.id}`, found);
|
|
1405
1401
|
}
|
|
@@ -1418,6 +1414,57 @@ export class AttachedDocument {
|
|
|
1418
1414
|
this.fail(error, false);
|
|
1419
1415
|
}
|
|
1420
1416
|
}
|
|
1417
|
+
/**
|
|
1418
|
+
* The generation cutover at load (`room.generation.convert`): the
|
|
1419
|
+
* records of a generation-4 container are read as JSON
|
|
1420
|
+
* (`crdt.generations.previousJSON`), which this generation seeds as
|
|
1421
|
+
* `onLoad`'s JSON is (deterministic: every replica converting the same
|
|
1422
|
+
* state writes the same update), and the container is replaced by it in
|
|
1423
|
+
* one storage transaction: rows, replicas, purge epochs, slot editors
|
|
1424
|
+
* and the stored restore go; the document data, the alarm's due tasks
|
|
1425
|
+
* and `lastUpdated` stay. What a generation-4 client never sent is not
|
|
1426
|
+
* in it; history, attribution and CRDT identity do not cross.
|
|
1427
|
+
*/
|
|
1428
|
+
convert(records) {
|
|
1429
|
+
const json = crdt.generations.previousJSON(records.map((record) => (record.v2 ? { v2: record.bytes } : record.bytes)), (doc) => this.facadeOf(doc));
|
|
1430
|
+
const scratch = crdt.createDoc();
|
|
1431
|
+
const facade = this.facadeOf(scratch);
|
|
1432
|
+
facade.seed(json.children, json.data);
|
|
1433
|
+
facade.dispose();
|
|
1434
|
+
const update = Y.encodeStateAsUpdate(scratch);
|
|
1435
|
+
scratch.destroy();
|
|
1436
|
+
const doc = admit(update, `room ${this.ctx.id} conversion`, () => this.restoreKeep);
|
|
1437
|
+
const snapshot = liveState(doc);
|
|
1438
|
+
this.ctx.storage.transactionSync(() => {
|
|
1439
|
+
for (const table of [
|
|
1440
|
+
this.rowsTable,
|
|
1441
|
+
this.replicasTable,
|
|
1442
|
+
this.epochsTable,
|
|
1443
|
+
this.editorsTable,
|
|
1444
|
+
this.restoreTable
|
|
1445
|
+
])
|
|
1446
|
+
this.sql.exec(`DELETE FROM ${table}`);
|
|
1447
|
+
this.nextRecord = 0;
|
|
1448
|
+
this.insert('snapshot', snapshot);
|
|
1449
|
+
this.touch();
|
|
1450
|
+
for (const replica of stateVector(doc).keys())
|
|
1451
|
+
this.sql.exec(`INSERT OR IGNORE INTO ${this.replicasTable} (replica, user) VALUES (?, '')`, replica);
|
|
1452
|
+
});
|
|
1453
|
+
this.restoreStep = null;
|
|
1454
|
+
this.format = STORED_GENERATION_RECORD.storage;
|
|
1455
|
+
this.documentBytes = snapshot.length;
|
|
1456
|
+
this.updates = 0;
|
|
1457
|
+
this.storedWaiting = Y.createIdSet();
|
|
1458
|
+
this.adopt(doc);
|
|
1459
|
+
this.origin = { kind: 'converted', from: E.PREVIOUS_SCHEMA, records: records.length };
|
|
1460
|
+
this.log({
|
|
1461
|
+
edytor: 'convert',
|
|
1462
|
+
from: E.PREVIOUS_SCHEMA,
|
|
1463
|
+
to: E.SCHEMA_VERSION,
|
|
1464
|
+
blocks: countBlocks(json.children),
|
|
1465
|
+
bytes: snapshot.length
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1421
1468
|
/** The bytes of the stored `pending` records (uncompressed, as every record but a snapshot). */
|
|
1422
1469
|
pendingBytes() {
|
|
1423
1470
|
return (this.sql
|
|
@@ -1441,7 +1488,7 @@ export class AttachedDocument {
|
|
|
1441
1488
|
if (records[0]?.kind === 'generation') {
|
|
1442
1489
|
try {
|
|
1443
1490
|
const found = JSON.parse(new TextDecoder().decode(records[0].bytes));
|
|
1444
|
-
if (isGenerationRecord(found))
|
|
1491
|
+
if (isGenerationRecord(found) || E.isPreviousGenerationRecord(found))
|
|
1445
1492
|
format = storageOf(found);
|
|
1446
1493
|
}
|
|
1447
1494
|
catch {
|
|
@@ -1831,7 +1878,17 @@ export class AttachedDocument {
|
|
|
1831
1878
|
return null;
|
|
1832
1879
|
const bytes = await gunzip(new Uint8Array(value));
|
|
1833
1880
|
return noTimers(() => {
|
|
1834
|
-
|
|
1881
|
+
let doc;
|
|
1882
|
+
try {
|
|
1883
|
+
doc = admit([{ v2: bytes }], `room ${this.ctx.id} version ${key}`, () => null);
|
|
1884
|
+
}
|
|
1885
|
+
catch (error) {
|
|
1886
|
+
// A version written by generation 4 reads through its JSON (`room.generation.convert`).
|
|
1887
|
+
const problem = error.problem;
|
|
1888
|
+
if (problem?.kind !== 'unsupported' || problem.version !== E.PREVIOUS_SCHEMA)
|
|
1889
|
+
throw error;
|
|
1890
|
+
return crdt.generations.previousJSON([{ v2: bytes }], (d) => this.facadeOf(d));
|
|
1891
|
+
}
|
|
1835
1892
|
const facade = this.facadeOf(doc);
|
|
1836
1893
|
try {
|
|
1837
1894
|
return facade.toJSON();
|
|
@@ -2688,7 +2745,11 @@ export class AttachedDocument {
|
|
|
2688
2745
|
if (syncType !== E.messageYjsSyncStep2 && syncType !== E.messageYjsUpdate) {
|
|
2689
2746
|
return this.refuse(ws, { reason: 'malformed', detail: `sync type ${syncType}` });
|
|
2690
2747
|
}
|
|
2691
|
-
|
|
2748
|
+
// A SyncStep2 is v2 on the wire (P5): every path below reads v1.
|
|
2749
|
+
const update = decode(() => {
|
|
2750
|
+
const payload = E.readVarUint8Array(decoder);
|
|
2751
|
+
return syncType === E.messageYjsSyncStep2 ? sync.step2Update(payload) : payload;
|
|
2752
|
+
});
|
|
2692
2753
|
const started = now();
|
|
2693
2754
|
try {
|
|
2694
2755
|
this.onUpdate(ws, attachment, doc, update);
|
package/dist/crdt/data.js
CHANGED
|
@@ -15,7 +15,8 @@ const itemish = (s) => /^~[a-z]/.test(s);
|
|
|
15
15
|
export const keySegment = (k) => (k.startsWith('~') ? `~0${k.slice(1)}` : k);
|
|
16
16
|
/** The object key a path segment names (`keySegment`'s inverse). */
|
|
17
17
|
export const segmentKey = (s) => (s.startsWith('~0') ? `~${s.slice(2)}` : s);
|
|
18
|
-
|
|
18
|
+
/** A rank's characters (`placement/rank.ts`, P7: variable-length digits, `!` before a tie). */
|
|
19
|
+
const RANK = /^[-\w?!]+$/;
|
|
19
20
|
const index = (k) => (/^(0|[1-9]\d*)$/.test(k) ? Number(k) : -1);
|
|
20
21
|
const rank = (v) => (typeof v === 'string' && RANK.test(v) ? v : undefined);
|
|
21
22
|
/** Item `id`'s place: where a move put it (`id>`), else where it was made (`id#`). */
|
package/dist/crdt/document.d.ts
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EdytorDocument` — the assembled document: one composition/lifecycle
|
|
3
|
+
* owner binding the production engine once and composing the existing
|
|
4
|
+
* domain facade, the default local history, one shared awareness, the
|
|
5
|
+
* document-level semantic configuration (block roles + default type) and
|
|
6
|
+
* the local actor identity (compact per-block attribution, U1+U2).
|
|
7
|
+
*
|
|
8
|
+
* Used headlessly or shared by any number of `Edytor` views:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const document = createDocument({ value, actor: { id: 'user-42', name: 'Ada' } });
|
|
12
|
+
* document.facade.insertText(bootstrapId, 0, 'hello');
|
|
13
|
+
* document.history.undo();
|
|
14
|
+
* const saved = document.encode();
|
|
15
|
+
* const restored = loadDocument(saved);
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* ```svelte
|
|
19
|
+
* <Edytor {document} />
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* ── Owned vs borrowed ──────────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* `createDocument`/`loadDocument` allocate a FRESH engine doc — the
|
|
25
|
+
* document owns it and `destroy()` releases everything exactly once
|
|
26
|
+
* (undo manager, facade/run-view lease, awareness, doc, sync cleanups).
|
|
27
|
+
* `attachDocument` composes the same services around a doc the CALLER
|
|
28
|
+
* owns: `destroy()` releases the document-created services but never
|
|
29
|
+
* `doc.destroy()`s the borrowed doc (borrowing grants no destruction
|
|
30
|
+
* authority — U1 contract). The same rule applies to an injected
|
|
31
|
+
* `awareness` (the `_ownsAwareness` convention already used by
|
|
32
|
+
* `IndexeddbPersistence`).
|
|
33
|
+
*
|
|
34
|
+
* ── Readiness ──────────────────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* `createDocument({ value })` seeds synchronously (local-first) and is
|
|
37
|
+
* `ready` on return. A document created WITHOUT a value stays `pending`:
|
|
38
|
+
* no bootstrap is stamped, no version written, because a provider may
|
|
39
|
+
* still hydrate it. `sync(value?)` is the single explicit readiness
|
|
40
|
+
* transition — the same deferral `Edytor.sync()` had: assert-and-adopt
|
|
41
|
+
* on an already-initialized (hydrated) doc, `init` on a still-fresh one,
|
|
42
|
+
* then the history attaches (bootstrap-before-capture — `createUndoManager`
|
|
43
|
+
* on the facade keeps that invariant internally too). `loadDocument`
|
|
44
|
+
* validates the payload BEFORE the facade or any capture attaches, then
|
|
45
|
+
* arrives ready.
|
|
46
|
+
*
|
|
47
|
+
* Per-state legality: `pending` — reads (`facade.project`/`toJSON`),
|
|
48
|
+
* `attachSync`, `adoptSemantics`, `trackOrigin`, `sync()`, `destroy()`
|
|
49
|
+
* are legal; `history` throws {@link DocumentNotReadyError}; nothing
|
|
50
|
+
* bootstrap-shaped is written or broadcast, so writes made raw against a
|
|
51
|
+
* pending doc produce unversioned state that `sync()` then refuses (see
|
|
52
|
+
* below). `local`/`hydrated` — the full surface is legal; the only
|
|
53
|
+
* difference is WHO decided the content. (One exception to "nothing
|
|
54
|
+
* broadcast": the actor dictionary attach publishes this replica's
|
|
55
|
+
* `c/<clientID>`/`u/<actorId>` records on ANY constructed document —
|
|
56
|
+
* identity metadata, not content, and outside every admission gate.)
|
|
57
|
+
*
|
|
58
|
+
* ── Admission boundary (U8) ────────────────────────────────────────────
|
|
59
|
+
*
|
|
60
|
+
* Every content-entry path crosses the one document-level admission gate
|
|
61
|
+
* (`src/lib/crdt/admission.ts` — shared vocabulary with the transport
|
|
62
|
+
* layer's staged-update gate):
|
|
63
|
+
*
|
|
64
|
+
* - `createDocument` — the fresh doc is trivially admitted;
|
|
65
|
+
* - `loadDocument` — the payload is decoded+integrated onto a SCRATCH
|
|
66
|
+
* doc first (`admitUpdate`) and the gate runs on the merged result; a
|
|
67
|
+
* refusal ({@link UndecodableUpdateError}, {@link UnsupportedDocError},
|
|
68
|
+
* {@link SchemaMismatchError}) leaves nothing composed and never
|
|
69
|
+
* touches the caller's bytes;
|
|
70
|
+
* - `attachDocument` — the borrowed doc is gated BEFORE any composition
|
|
71
|
+
* touches it; a refusal leaves it byte-identical (and re-attachable
|
|
72
|
+
* once its state heals). It never auto-seeds: the doc always attaches
|
|
73
|
+
* `pending` and only `sync()`/migration decides content state;
|
|
74
|
+
* - `sync()` — re-runs the gate at the readiness decision: provider or
|
|
75
|
+
* raw writes may have landed since attach. A doc that fails now
|
|
76
|
+
* refuses readiness with the typed error (stays `pending`, content
|
|
77
|
+
* preserved) rather than `init` stamping a schema over unversioned
|
|
78
|
+
* content; a `'fresh'` verdict seeds (`local`), an `'initialized'`
|
|
79
|
+
* verdict adopts (`hydrated`). Idempotent once ready.
|
|
80
|
+
*
|
|
81
|
+
* Refusal preserves data — the gate reads are write-free: refused docs
|
|
82
|
+
* keep their content, refused updates keep their bytes, and provider-side
|
|
83
|
+
* refusals keep their stored rows (`IndexeddbPersistence` compaction
|
|
84
|
+
* stays blocked while a refused row set cannot be represented by a
|
|
85
|
+
* compacted snapshot).
|
|
86
|
+
*
|
|
87
|
+
* ── Semantic configuration ─────────────────────────────────────────────
|
|
88
|
+
*
|
|
89
|
+
* The adopted capability (rule R5) is document-level state that outlives
|
|
90
|
+
* any single view: structural roles (`void`/`island`), whether a kind
|
|
91
|
+
* renders its own content (`rendersContent`), the default child type per
|
|
92
|
+
* parent type (`defaultChild`) and `defaultType`. Views contribute what
|
|
93
|
+
* their plugin block definitions declare via {@link adoptSemantics}: the
|
|
94
|
+
* first declaration for a type is adopted, a later CONFLICTING one is an
|
|
95
|
+
* error, and a refused contribution adopts nothing (views cannot silently
|
|
96
|
+
* impose incompatible structural rules on a shared document). Snippets
|
|
97
|
+
* and DOM hooks stay view-side.
|
|
98
|
+
*
|
|
99
|
+
* ── History ────────────────────────────────────────────────────────────
|
|
100
|
+
*
|
|
101
|
+
* One registry-scoped `Y.UndoManager` per document (the facade's
|
|
102
|
+
* supported seam). Capture is gated by TWO live checks, both consulted
|
|
103
|
+
* on every committing transaction:
|
|
104
|
+
*
|
|
105
|
+
* - `trackedOrigins` — a LIVE per-document set checked by identity: the
|
|
106
|
+
* document's own headless origin, `null` (untyped local transactions)
|
|
107
|
+
* and every view's `TRANSACTION` instance registered via
|
|
108
|
+
* {@link trackOrigin} — each `Edytor` keeps its own instance so the
|
|
109
|
+
* mirror can still tell its own commits from a sibling view's, while
|
|
110
|
+
* identity tracking keeps undo LOCAL unless the document is actually
|
|
111
|
+
* shared.
|
|
112
|
+
* - `captureTransaction` — `transaction.local !== false`. The engine
|
|
113
|
+
* marks every update-integration transaction non-local: `applyUpdate`
|
|
114
|
+
* runs `transact(..., local=false)` AND forces `transaction.local =
|
|
115
|
+
* false` on the in-flight transaction, so even an apply nested inside
|
|
116
|
+
* a local `transact` marks the whole commit non-local. This is the
|
|
117
|
+
* remote-exclusion contract: NO provider path or raw
|
|
118
|
+
* `applyUpdate(doc, u)` — stamped or originless — can ever enter the
|
|
119
|
+
* local user undo stack, and the guarantee cannot be bypassed by a
|
|
120
|
+
* provider forgetting to stamp an origin. (Provider paths still stamp
|
|
121
|
+
* their instance as origin — required anyway so `doc.on('update')`
|
|
122
|
+
* echo suppression keeps working — see `protocols/sync.ts`
|
|
123
|
+
* `remoteApplyOrigin` for the residual no-origin default.)
|
|
124
|
+
*
|
|
125
|
+
* `null` therefore carries only LOCAL untyped transactions now — direct
|
|
126
|
+
* `document.facade.<op>` calls outside `document.transact`, raw
|
|
127
|
+
* `doc.transact(fn)` writes. Keeping it tracked preserves the headless
|
|
128
|
+
* contract (`document.facade.insertText(...)` stays undoable); callers
|
|
129
|
+
* wanting per-action undo grouping should prefer `document.transact`.
|
|
130
|
+
* Bookkeeping writes that must never enter user history run under an
|
|
131
|
+
* UNTRACKED origin (the undo-ownership repair's `UNDO_REPAIR_ORIGIN`
|
|
132
|
+
* Symbol and the actor-dictionary's `ATTRIBUTION_ORIGIN` are the existing
|
|
133
|
+
* examples) or inside a non-local transaction; either channel is
|
|
134
|
+
* sufficient and both are free of the local undo stack.
|
|
135
|
+
*
|
|
136
|
+
* History is lazy: it attaches at `sync()`, or on first `history` access
|
|
137
|
+
* once the doc is initialized — it NEVER initializes content itself, so
|
|
138
|
+
* bootstrap/hydration writes all predate capture and are naturally
|
|
139
|
+
* excluded. Stacks are unbounded (the engine prunes nothing); prune
|
|
140
|
+
* explicitly via {@link clearHistory} / `history.clear()`, and tune the
|
|
141
|
+
* merge window via `history.captureTimeout`.
|
|
142
|
+
*/
|
|
143
|
+
import type { MarkEdge } from './text/marks.js';
|
|
1
144
|
import type { EngineApi, YDoc, YUndoManager } from './engine-api.js';
|
|
2
145
|
import { type AttributionBinding, type DocumentAttribution } from './attribution/index.js';
|
|
3
146
|
import { type BlockRole, type EdytorDoc, type EdytorDocBinding } from './edytor-doc.js';
|
|
@@ -32,6 +175,13 @@ export type DocumentSemanticsConfig = {
|
|
|
32
175
|
defaultChild?: Record<string, string>;
|
|
33
176
|
/** Default block type — the root's default child and the bootstrap block. */
|
|
34
177
|
defaultType?: string;
|
|
178
|
+
/**
|
|
179
|
+
* H5: per mark, its edge (its record's `edge`) — where a concurrent insert
|
|
180
|
+
* at each end of a mark lands. Undeclared marks are `inclusive`.
|
|
181
|
+
*/
|
|
182
|
+
marks?: Record<string, {
|
|
183
|
+
edge?: MarkEdge;
|
|
184
|
+
}>;
|
|
35
185
|
};
|
|
36
186
|
export type DocumentOptions = {
|
|
37
187
|
/** Local actor — anonymous opaque id when absent. */
|
|
@@ -340,6 +490,7 @@ export declare class EdytorDocument {
|
|
|
340
490
|
roles: Map<string, BlockRole>;
|
|
341
491
|
rendersContent: Map<string, boolean>;
|
|
342
492
|
defaultChild: Map<string, string>;
|
|
493
|
+
marks: Map<string, MarkEdge>;
|
|
343
494
|
};
|
|
344
495
|
/**
|
|
345
496
|
* The default child type under a parent of `parentType` (`null` = the
|
package/dist/crdt/document.js
CHANGED
|
@@ -1,145 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `EdytorDocument` — the assembled document: one composition/lifecycle
|
|
3
|
-
* owner binding the production engine once and composing the existing
|
|
4
|
-
* domain facade, the default local history, one shared awareness, the
|
|
5
|
-
* document-level semantic configuration (block roles + default type) and
|
|
6
|
-
* the local actor identity (compact per-block attribution, U1+U2).
|
|
7
|
-
*
|
|
8
|
-
* Used headlessly or shared by any number of `Edytor` views:
|
|
9
|
-
*
|
|
10
|
-
* ```ts
|
|
11
|
-
* const document = createDocument({ value, actor: { id: 'user-42', name: 'Ada' } });
|
|
12
|
-
* document.facade.insertText(bootstrapId, 0, 'hello');
|
|
13
|
-
* document.history.undo();
|
|
14
|
-
* const saved = document.encode();
|
|
15
|
-
* const restored = loadDocument(saved);
|
|
16
|
-
* ```
|
|
17
|
-
*
|
|
18
|
-
* ```svelte
|
|
19
|
-
* <Edytor {document} />
|
|
20
|
-
* ```
|
|
21
|
-
*
|
|
22
|
-
* ── Owned vs borrowed ──────────────────────────────────────────────────
|
|
23
|
-
*
|
|
24
|
-
* `createDocument`/`loadDocument` allocate a FRESH engine doc — the
|
|
25
|
-
* document owns it and `destroy()` releases everything exactly once
|
|
26
|
-
* (undo manager, facade/run-view lease, awareness, doc, sync cleanups).
|
|
27
|
-
* `attachDocument` composes the same services around a doc the CALLER
|
|
28
|
-
* owns: `destroy()` releases the document-created services but never
|
|
29
|
-
* `doc.destroy()`s the borrowed doc (borrowing grants no destruction
|
|
30
|
-
* authority — U1 contract). The same rule applies to an injected
|
|
31
|
-
* `awareness` (the `_ownsAwareness` convention already used by
|
|
32
|
-
* `IndexeddbPersistence`).
|
|
33
|
-
*
|
|
34
|
-
* ── Readiness ──────────────────────────────────────────────────────────
|
|
35
|
-
*
|
|
36
|
-
* `createDocument({ value })` seeds synchronously (local-first) and is
|
|
37
|
-
* `ready` on return. A document created WITHOUT a value stays `pending`:
|
|
38
|
-
* no bootstrap is stamped, no version written, because a provider may
|
|
39
|
-
* still hydrate it. `sync(value?)` is the single explicit readiness
|
|
40
|
-
* transition — the same deferral `Edytor.sync()` had: assert-and-adopt
|
|
41
|
-
* on an already-initialized (hydrated) doc, `init` on a still-fresh one,
|
|
42
|
-
* then the history attaches (bootstrap-before-capture — `createUndoManager`
|
|
43
|
-
* on the facade keeps that invariant internally too). `loadDocument`
|
|
44
|
-
* validates the payload BEFORE the facade or any capture attaches, then
|
|
45
|
-
* arrives ready.
|
|
46
|
-
*
|
|
47
|
-
* Per-state legality: `pending` — reads (`facade.project`/`toJSON`),
|
|
48
|
-
* `attachSync`, `adoptSemantics`, `trackOrigin`, `sync()`, `destroy()`
|
|
49
|
-
* are legal; `history` throws {@link DocumentNotReadyError}; nothing
|
|
50
|
-
* bootstrap-shaped is written or broadcast, so writes made raw against a
|
|
51
|
-
* pending doc produce unversioned state that `sync()` then refuses (see
|
|
52
|
-
* below). `local`/`hydrated` — the full surface is legal; the only
|
|
53
|
-
* difference is WHO decided the content. (One exception to "nothing
|
|
54
|
-
* broadcast": the actor dictionary attach publishes this replica's
|
|
55
|
-
* `c/<clientID>`/`u/<actorId>` records on ANY constructed document —
|
|
56
|
-
* identity metadata, not content, and outside every admission gate.)
|
|
57
|
-
*
|
|
58
|
-
* ── Admission boundary (U8) ────────────────────────────────────────────
|
|
59
|
-
*
|
|
60
|
-
* Every content-entry path crosses the one document-level admission gate
|
|
61
|
-
* (`src/lib/crdt/admission.ts` — shared vocabulary with the transport
|
|
62
|
-
* layer's staged-update gate):
|
|
63
|
-
*
|
|
64
|
-
* - `createDocument` — the fresh doc is trivially admitted;
|
|
65
|
-
* - `loadDocument` — the payload is decoded+integrated onto a SCRATCH
|
|
66
|
-
* doc first (`admitUpdate`) and the gate runs on the merged result; a
|
|
67
|
-
* refusal ({@link UndecodableUpdateError}, {@link UnsupportedDocError},
|
|
68
|
-
* {@link SchemaMismatchError}) leaves nothing composed and never
|
|
69
|
-
* touches the caller's bytes;
|
|
70
|
-
* - `attachDocument` — the borrowed doc is gated BEFORE any composition
|
|
71
|
-
* touches it; a refusal leaves it byte-identical (and re-attachable
|
|
72
|
-
* once its state heals). It never auto-seeds: the doc always attaches
|
|
73
|
-
* `pending` and only `sync()`/migration decides content state;
|
|
74
|
-
* - `sync()` — re-runs the gate at the readiness decision: provider or
|
|
75
|
-
* raw writes may have landed since attach. A doc that fails now
|
|
76
|
-
* refuses readiness with the typed error (stays `pending`, content
|
|
77
|
-
* preserved) rather than `init` stamping a schema over unversioned
|
|
78
|
-
* content; a `'fresh'` verdict seeds (`local`), an `'initialized'`
|
|
79
|
-
* verdict adopts (`hydrated`). Idempotent once ready.
|
|
80
|
-
*
|
|
81
|
-
* Refusal preserves data — the gate reads are write-free: refused docs
|
|
82
|
-
* keep their content, refused updates keep their bytes, and provider-side
|
|
83
|
-
* refusals keep their stored rows (`IndexeddbPersistence` compaction
|
|
84
|
-
* stays blocked while a refused row set cannot be represented by a
|
|
85
|
-
* compacted snapshot).
|
|
86
|
-
*
|
|
87
|
-
* ── Semantic configuration ─────────────────────────────────────────────
|
|
88
|
-
*
|
|
89
|
-
* The adopted capability (rule R5) is document-level state that outlives
|
|
90
|
-
* any single view: structural roles (`void`/`island`), whether a kind
|
|
91
|
-
* renders its own content (`rendersContent`), the default child type per
|
|
92
|
-
* parent type (`defaultChild`) and `defaultType`. Views contribute what
|
|
93
|
-
* their plugin block definitions declare via {@link adoptSemantics}: the
|
|
94
|
-
* first declaration for a type is adopted, a later CONFLICTING one is an
|
|
95
|
-
* error, and a refused contribution adopts nothing (views cannot silently
|
|
96
|
-
* impose incompatible structural rules on a shared document). Snippets
|
|
97
|
-
* and DOM hooks stay view-side.
|
|
98
|
-
*
|
|
99
|
-
* ── History ────────────────────────────────────────────────────────────
|
|
100
|
-
*
|
|
101
|
-
* One registry-scoped `Y.UndoManager` per document (the facade's
|
|
102
|
-
* supported seam). Capture is gated by TWO live checks, both consulted
|
|
103
|
-
* on every committing transaction:
|
|
104
|
-
*
|
|
105
|
-
* - `trackedOrigins` — a LIVE per-document set checked by identity: the
|
|
106
|
-
* document's own headless origin, `null` (untyped local transactions)
|
|
107
|
-
* and every view's `TRANSACTION` instance registered via
|
|
108
|
-
* {@link trackOrigin} — each `Edytor` keeps its own instance so the
|
|
109
|
-
* mirror can still tell its own commits from a sibling view's, while
|
|
110
|
-
* identity tracking keeps undo LOCAL unless the document is actually
|
|
111
|
-
* shared.
|
|
112
|
-
* - `captureTransaction` — `transaction.local !== false`. The engine
|
|
113
|
-
* marks every update-integration transaction non-local: `applyUpdate`
|
|
114
|
-
* runs `transact(..., local=false)` AND forces `transaction.local =
|
|
115
|
-
* false` on the in-flight transaction, so even an apply nested inside
|
|
116
|
-
* a local `transact` marks the whole commit non-local. This is the
|
|
117
|
-
* remote-exclusion contract: NO provider path or raw
|
|
118
|
-
* `applyUpdate(doc, u)` — stamped or originless — can ever enter the
|
|
119
|
-
* local user undo stack, and the guarantee cannot be bypassed by a
|
|
120
|
-
* provider forgetting to stamp an origin. (Provider paths still stamp
|
|
121
|
-
* their instance as origin — required anyway so `doc.on('update')`
|
|
122
|
-
* echo suppression keeps working — see `protocols/sync.ts`
|
|
123
|
-
* `remoteApplyOrigin` for the residual no-origin default.)
|
|
124
|
-
*
|
|
125
|
-
* `null` therefore carries only LOCAL untyped transactions now — direct
|
|
126
|
-
* `document.facade.<op>` calls outside `document.transact`, raw
|
|
127
|
-
* `doc.transact(fn)` writes. Keeping it tracked preserves the headless
|
|
128
|
-
* contract (`document.facade.insertText(...)` stays undoable); callers
|
|
129
|
-
* wanting per-action undo grouping should prefer `document.transact`.
|
|
130
|
-
* Bookkeeping writes that must never enter user history run under an
|
|
131
|
-
* UNTRACKED origin (the undo-ownership repair's `UNDO_REPAIR_ORIGIN`
|
|
132
|
-
* Symbol and the actor-dictionary's `ATTRIBUTION_ORIGIN` are the existing
|
|
133
|
-
* examples) or inside a non-local transaction; either channel is
|
|
134
|
-
* sufficient and both are free of the local undo stack.
|
|
135
|
-
*
|
|
136
|
-
* History is lazy: it attaches at `sync()`, or on first `history` access
|
|
137
|
-
* once the doc is initialized — it NEVER initializes content itself, so
|
|
138
|
-
* bootstrap/hydration writes all predate capture and are naturally
|
|
139
|
-
* excluded. Stacks are unbounded (the engine prunes nothing); prune
|
|
140
|
-
* explicitly via {@link clearHistory} / `history.clear()`, and tune the
|
|
141
|
-
* merge window via `history.captureTimeout`.
|
|
142
|
-
*/
|
|
143
1
|
import { DEV } from 'esm-env';
|
|
144
2
|
import { Y } from './engine.js';
|
|
145
3
|
import { bindAttribution } from './attribution/index.js';
|
|
@@ -313,7 +171,8 @@ export class EdytorDocument {
|
|
|
313
171
|
_capability = {
|
|
314
172
|
roles: new Map(),
|
|
315
173
|
rendersContent: new Map(),
|
|
316
|
-
defaultChild: new Map()
|
|
174
|
+
defaultChild: new Map(),
|
|
175
|
+
marks: new Map()
|
|
317
176
|
};
|
|
318
177
|
_defaultType;
|
|
319
178
|
_historyOptions;
|
|
@@ -337,6 +196,7 @@ export class EdytorDocument {
|
|
|
337
196
|
defaultType: this._defaultType,
|
|
338
197
|
defaultChildOf: (type) => this._capability.defaultChild.get(type),
|
|
339
198
|
rendersContent: (type) => this.rendersContent(type),
|
|
199
|
+
markEdge: (mark) => this._capability.marks.get(mark),
|
|
340
200
|
// U1: the facade's block-attribution ops read the actor lazily —
|
|
341
201
|
// `this.actor` is assigned below, after facade construction.
|
|
342
202
|
actor: () => this.actor,
|
|
@@ -455,12 +315,13 @@ export class EdytorDocument {
|
|
|
455
315
|
* copies — mutation is not supported; contribute via {@link adoptSemantics}.
|
|
456
316
|
*/
|
|
457
317
|
get semantics() {
|
|
458
|
-
const { roles, rendersContent, defaultChild } = this._capability;
|
|
318
|
+
const { roles, rendersContent, defaultChild, marks } = this._capability;
|
|
459
319
|
return {
|
|
460
320
|
defaultType: this._defaultType,
|
|
461
321
|
roles: new Map(roles),
|
|
462
322
|
rendersContent: new Map(rendersContent),
|
|
463
|
-
defaultChild: new Map(defaultChild)
|
|
323
|
+
defaultChild: new Map(defaultChild),
|
|
324
|
+
marks: new Map(marks)
|
|
464
325
|
};
|
|
465
326
|
}
|
|
466
327
|
/**
|
|
@@ -498,7 +359,11 @@ export class EdytorDocument {
|
|
|
498
359
|
const incoming = [
|
|
499
360
|
['roles', Object.entries(config.roles ?? {}).map(([t, r]) => [t, normalizeRole(r)])],
|
|
500
361
|
['rendersContent', Object.entries(config.rendersContent ?? {})],
|
|
501
|
-
['defaultChild', Object.entries(config.defaultChild ?? {})]
|
|
362
|
+
['defaultChild', Object.entries(config.defaultChild ?? {})],
|
|
363
|
+
[
|
|
364
|
+
'marks',
|
|
365
|
+
Object.entries(config.marks ?? {}).map(([mark, m]) => [mark, m?.edge ?? 'inclusive'])
|
|
366
|
+
]
|
|
502
367
|
];
|
|
503
368
|
// Pass 1 — validate EVERYTHING before mutating: a conflicting entry
|
|
504
369
|
// late in a table must not leave earlier entries half-adopted.
|