@panphora/clayjs 1.13.1 → 1.14.0
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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69667 -41708
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +94 -93
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +7 -3
- package/src/loader.js +6 -0
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1121 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +384 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/live-sync.js +13 -2
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import * as Y from "../../vendor/yjs.vendor.js";
|
|
2
|
+
import { REMOTE_META, LOCAL, HISTORY_META, attrKeys, sameJson, parseKey, mintOp } from "./layout.js";
|
|
3
|
+
import { walk, hashText } from "./walk.js";
|
|
4
|
+
import { docOf } from "./doc.js";
|
|
5
|
+
import { localGroup } from "./group.js";
|
|
6
|
+
import { tokenIndex, yBefore, yAfter } from "./tokens.js";
|
|
7
|
+
import { state as pmState } from "../../vendor/prosemirror.vendor.js";
|
|
8
|
+
import { repairSiblings } from "../model.js";
|
|
9
|
+
import { createOriginIndex, installUndoOrigins } from "./origins.js";
|
|
10
|
+
import { reduceRecovery, canonicalRevision, restoreKey, physicalKeyOf, authorKey, RECOVERED_PREFIX, parsePhysicalKey, parseGoneIntents } from "./recovery.js";
|
|
11
|
+
import { moveBlocks } from "./moves.js";
|
|
12
|
+
import { createBoardCommands } from "./board-commands.js";
|
|
13
|
+
import { MOVE, BlockSelection } from "../blocks.js";
|
|
14
|
+
|
|
15
|
+
const yIndexAt = (doc, holes, pos, visible) => yBefore(holes, tokenIndex(doc, new Map(), visible)[pos]);
|
|
16
|
+
|
|
17
|
+
export function posAtY(doc, holes, tokens, y, visible = null) {
|
|
18
|
+
let lo = 0, hi = tokens;
|
|
19
|
+
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (yBefore(holes, mid) <= y) lo = mid; else hi = mid - 1; }
|
|
20
|
+
const idx = tokenIndex(doc, new Map(), visible);
|
|
21
|
+
let first = -1;
|
|
22
|
+
for (let p = 0; p < idx.length; p++) {
|
|
23
|
+
if (idx[p] < lo) continue;
|
|
24
|
+
if (idx[p] > lo) break;
|
|
25
|
+
if (first < 0) first = p;
|
|
26
|
+
if (doc.resolve(p).parent.inlineContent) return p;
|
|
27
|
+
}
|
|
28
|
+
return first < 0 ? doc.content.size : first;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const shapeOf = (doc) => { const out = []; doc.forEach((b) => out.push([b.type.name, b.attrs.id, JSON.stringify(Object.fromEntries(attrKeys(b.type).map((k) => [k, b.attrs[k]]))), b.isTextblock || b.type.name === "table" ? JSON.stringify(b.content.toJSON()) : null])); return JSON.stringify(out); };
|
|
32
|
+
export const bareShape = (doc) => { const out = []; doc.forEach((b) => out.push([b.type.name, JSON.stringify(Object.fromEntries(attrKeys(b.type).map((k) => [k, b.attrs[k]]))), b.isTextblock || b.type.name === "table" ? JSON.stringify(b.content.toJSON()) : null])); return JSON.stringify(out); };
|
|
33
|
+
export function sameBareShape(a, b) {
|
|
34
|
+
if (a.childCount !== b.childCount) return false;
|
|
35
|
+
for (let i = 0; i < a.childCount; i++) {
|
|
36
|
+
const left = a.child(i), right = b.child(i);
|
|
37
|
+
if (left.type !== right.type) return false;
|
|
38
|
+
if (attrKeys(left.type).some((key) => !sameJson(left.attrs[key], right.attrs[key]))) return false;
|
|
39
|
+
if ((left.isTextblock || left.type.name === "table") && !left.content.eq(right.content)) return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tableSpan = (doc, pos) => {
|
|
45
|
+
const $pos = doc.resolve(pos);
|
|
46
|
+
return $pos.depth && $pos.node(1).type.name === "table" ? [$pos.before(1), $pos.after(1)] : null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function docDiff(oldDoc, newDoc) {
|
|
50
|
+
let start = oldDoc.content.findDiffStart(newDoc.content);
|
|
51
|
+
if (start === null) return null;
|
|
52
|
+
let { a: endA, b: endB } = oldDoc.content.findDiffEnd(newDoc.content);
|
|
53
|
+
const overlap = start - Math.min(endA, endB);
|
|
54
|
+
if (overlap > 0) { endA += overlap; endB += overlap; }
|
|
55
|
+
const oneTextblock = (doc, end) => { const $start = doc.resolve(start); return $start.parent.isTextblock && $start.sameParent(doc.resolve(end)); };
|
|
56
|
+
if ((tableSpan(oldDoc, start) || tableSpan(oldDoc, endA)) && !(oneTextblock(oldDoc, endA) && oneTextblock(newDoc, endB))) {
|
|
57
|
+
start = tableSpan(oldDoc, start)?.[0] ?? start;
|
|
58
|
+
endA = tableSpan(oldDoc, endA)?.[1] ?? endA;
|
|
59
|
+
endB = tableSpan(newDoc, endB)?.[1] ?? endB;
|
|
60
|
+
return { start, endA, endB, tables: true };
|
|
61
|
+
}
|
|
62
|
+
return { start, endA, endB };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cellPoint = (doc, pos) => {
|
|
66
|
+
const $pos = doc.resolve(pos);
|
|
67
|
+
if ($pos.depth !== 3 || $pos.parent.type.name !== "table_cell") return null;
|
|
68
|
+
return { table: $pos.node(1).attrs.id, row: $pos.node(2).attrs.id, col: $pos.parent.attrs.col, offset: $pos.parentOffset };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const cellAt = (doc, point) => {
|
|
72
|
+
let found = null;
|
|
73
|
+
doc.forEach((table, at) => {
|
|
74
|
+
if (found !== null || table.type.name !== "table" || table.attrs.id !== point.table) return;
|
|
75
|
+
table.forEach((row, rowAt) => {
|
|
76
|
+
if (found !== null || row.attrs.id !== point.row) return;
|
|
77
|
+
row.forEach((cell, cellAt) => {
|
|
78
|
+
if (found === null && cell.attrs.col === point.col) found = at + 2 + rowAt + cellAt + 1 + Math.min(point.offset, cell.content.size);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
return found;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
export function keepViewPos(derived, current = null) {
|
|
87
|
+
const previous = new Map();
|
|
88
|
+
current?.forEach((block) => { if (block.attrs.id) previous.set(block.attrs.id, block); });
|
|
89
|
+
const blocks = [];
|
|
90
|
+
derived.forEach((block) => blocks.push(block));
|
|
91
|
+
const positions = blocks.map((block) => ({ id: block.attrs.id, pos: previous.get(block.attrs.id)?.attrs.pos ?? block.attrs.pos }));
|
|
92
|
+
const changes = repairSiblings(positions, "b");
|
|
93
|
+
let changed = false;
|
|
94
|
+
const placed = blocks.map((block, index) => {
|
|
95
|
+
const pos = changes[index]?.pos ?? positions[index].pos;
|
|
96
|
+
let content = block.content;
|
|
97
|
+
if (block.type.name === "table") {
|
|
98
|
+
const rows = [];
|
|
99
|
+
block.forEach((row) => rows.push(row));
|
|
100
|
+
const rowPositions = rows.map((row) => ({ id: row.attrs.id, pos: row.attrs.pos }));
|
|
101
|
+
const rowChanges = repairSiblings(rowPositions, "r");
|
|
102
|
+
const placedRows = rows.map((row, i) => {
|
|
103
|
+
const rowPos = rowChanges[i]?.pos ?? rowPositions[i].pos;
|
|
104
|
+
return rowPos === row.attrs.pos ? row : row.type.create({ ...row.attrs, pos: rowPos }, row.content, row.marks);
|
|
105
|
+
});
|
|
106
|
+
if (placedRows.some((row, i) => row !== rows[i])) content = placedRows;
|
|
107
|
+
}
|
|
108
|
+
if (pos === block.attrs.pos && content === block.content) return block;
|
|
109
|
+
changed = true;
|
|
110
|
+
return block.type.create({ ...block.attrs, pos }, content, block.marks);
|
|
111
|
+
});
|
|
112
|
+
return changed ? derived.type.create(derived.attrs, placed) : derived;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const freezeDeep = (value) => {
|
|
116
|
+
if (value && typeof value === "object") {
|
|
117
|
+
for (const key of Object.keys(value)) freezeDeep(value[key]);
|
|
118
|
+
Object.freeze(value);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
};
|
|
122
|
+
const detached = (value) => freezeDeep(JSON.parse(JSON.stringify(value)));
|
|
123
|
+
|
|
124
|
+
const restoreIndexAt = (pd, sourceKey) => {
|
|
125
|
+
const target = parseKey(sourceKey);
|
|
126
|
+
let at = 0;
|
|
127
|
+
for (let it = pd.flow._start; it; it = it.right) {
|
|
128
|
+
if (it.id.client === target.client && it.id.clock === target.clock) return it.deleted ? at : at + it.length;
|
|
129
|
+
if (!it.deleted && it.countable) at += it.length;
|
|
130
|
+
}
|
|
131
|
+
return at;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const newestLocalMarker = (pd, blockId) => {
|
|
135
|
+
let found = null;
|
|
136
|
+
for (let it = pd.flow._start; it; it = it.right) {
|
|
137
|
+
if (it.id.client !== pd.ydoc.clientID || !(it.content instanceof Y.ContentEmbed) || it.content.embed?.b !== blockId) continue;
|
|
138
|
+
if (!found || it.id.clock > found.clock) found = it.id;
|
|
139
|
+
}
|
|
140
|
+
return found;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export function createBridge({ pd, onNotice = () => {}, ownedTokens = [], authorToken = null, stats = { groups: 0, mismatch: 0, renders: 0, first: null }, captureTimeout = 500 }) {
|
|
144
|
+
let view = null;
|
|
145
|
+
let w = walk(pd);
|
|
146
|
+
const origins = createOriginIndex(pd.origins);
|
|
147
|
+
const um = new Y.UndoManager([pd.flow, pd.attrs, pd.gone, pd.tableFields, pd.boardFields], {
|
|
148
|
+
trackedOrigins: new Set([LOCAL]),
|
|
149
|
+
captureTimeout,
|
|
150
|
+
captureTransaction: (tr) => tr.meta.get(HISTORY_META) !== false,
|
|
151
|
+
deleteFilter: (item) => {
|
|
152
|
+
if (item.parent !== pd.flow || !(item.content instanceof Y.ContentEmbed)) return true;
|
|
153
|
+
const source = origins.resolve(item.id);
|
|
154
|
+
return source.client !== item.id.client || source.clock !== item.id.clock || origins.boundaryOf(item.id)?.kind !== "copy";
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const offOrigins = installUndoOrigins(pd, um, origins, (gone) => parseGoneIntents(gone).textDeletes);
|
|
158
|
+
const notified = new Set();
|
|
159
|
+
const localTokens = new Set(authorToken ? [...ownedTokens, authorToken] : ownedTokens);
|
|
160
|
+
let registerAuthor = authorToken ? () => {
|
|
161
|
+
const key = authorKey(pd.ydoc.clientID);
|
|
162
|
+
if (pd.origins.get(key) !== authorToken) pd.origins.set(key, authorToken);
|
|
163
|
+
} : null;
|
|
164
|
+
const dismissed = new Set();
|
|
165
|
+
const recoveryListeners = new Set();
|
|
166
|
+
let recoveryVersion = 0;
|
|
167
|
+
let recoveryMemo = null;
|
|
168
|
+
let recoverySignature = null;
|
|
169
|
+
const personalNotice = (record) => {
|
|
170
|
+
const owners = new Set();
|
|
171
|
+
for (const block of record.blocks) for (const run of block.runs) {
|
|
172
|
+
if (!run.displaced) continue;
|
|
173
|
+
const ownClient = parsePhysicalKey(run.source)?.client === pd.ydoc.clientID;
|
|
174
|
+
if (run.author && (ownClient || localTokens.has(run.author))) owners.add(run.author);
|
|
175
|
+
else if (ownClient) owners.add(null);
|
|
176
|
+
}
|
|
177
|
+
if (!owners.size) return null;
|
|
178
|
+
const authors = [...owners].filter(Boolean).sort();
|
|
179
|
+
return { kind: "recovered-change", source: record.source, revision: record.revision, blockId: record.blockId, author: authors[0] ?? null, authors };
|
|
180
|
+
};
|
|
181
|
+
const recoveryState = () => {
|
|
182
|
+
if (recoveryMemo && recoveryMemo.version === recoveryVersion) return recoveryMemo.snapshot;
|
|
183
|
+
const reduced = reduceRecovery(pd, { index: origins });
|
|
184
|
+
const recoverable = reduced.list.filter((record) => record.recoverable).map((record) => detached(record));
|
|
185
|
+
const notices = detached(recoverable.map((record) => personalNotice(record)).filter((notice) => notice && !dismissed.has(`${notice.source}\u0000${notice.revision}`)));
|
|
186
|
+
const snapshot = Object.freeze({ recoverable: Object.freeze(recoverable), notices: Object.freeze(notices) });
|
|
187
|
+
recoveryMemo = { version: recoveryVersion, snapshot };
|
|
188
|
+
return snapshot;
|
|
189
|
+
};
|
|
190
|
+
const recoverySignatureOf = (snapshot) => canonicalRevision({ recoverable: snapshot.recoverable.map((record) => [record.source, record.revision]), notices: snapshot.notices });
|
|
191
|
+
const bumpRecovery = () => {
|
|
192
|
+
recoveryVersion++;
|
|
193
|
+
const snapshot = recoveryState();
|
|
194
|
+
for (const notice of snapshot.notices) {
|
|
195
|
+
const key = `${notice.source}\u0000${notice.revision}`;
|
|
196
|
+
if (!notified.has(key)) { notified.add(key); onNotice(notice); }
|
|
197
|
+
}
|
|
198
|
+
if (!recoveryListeners.size) { recoverySignature = null; return; }
|
|
199
|
+
const signature = recoverySignatureOf(snapshot);
|
|
200
|
+
if (signature === recoverySignature) return;
|
|
201
|
+
recoverySignature = signature;
|
|
202
|
+
for (const listener of [...recoveryListeners]) listener(snapshot);
|
|
203
|
+
};
|
|
204
|
+
const refresh = () => {
|
|
205
|
+
w = walk(pd);
|
|
206
|
+
bumpRecovery();
|
|
207
|
+
return docOf(pd, w);
|
|
208
|
+
};
|
|
209
|
+
let saved = null;
|
|
210
|
+
const render = (own = false, fresh = null) => {
|
|
211
|
+
stats.renders++;
|
|
212
|
+
const keep = saved;
|
|
213
|
+
saved = null;
|
|
214
|
+
const derived = keepViewPos(fresh ?? refresh(), view.state.doc);
|
|
215
|
+
const d = docDiff(view.state.doc, derived);
|
|
216
|
+
if (!d) return;
|
|
217
|
+
const tr = view.state.tr.replace(d.start, d.endA, derived.slice(d.start, d.endB)).setMeta(REMOTE_META, true).setMeta("addToHistory", false);
|
|
218
|
+
if (own) tr.setMeta("yundo", true);
|
|
219
|
+
if (keep) {
|
|
220
|
+
const hiddenUnit = (y) => y >= 0 && w.holes.some((hole) => y >= yBefore(w.holes, hole.at) && y < yAfter(w.holes, hole.at));
|
|
221
|
+
const placeIn = (id, offset) => {
|
|
222
|
+
let found = null;
|
|
223
|
+
tr.doc.forEach((node, pos) => { if (found === null && node.attrs.id === id && node.isTextblock) found = pos + 1 + Math.min(offset, node.content.size); });
|
|
224
|
+
return found;
|
|
225
|
+
};
|
|
226
|
+
const at = (end) => {
|
|
227
|
+
const abs = Y.createAbsolutePositionFromRelativePosition(end.right, pd.ydoc);
|
|
228
|
+
if (!abs) return null;
|
|
229
|
+
const pos = posAtY(tr.doc, w.holes, w.tokens, abs.index, w.visible);
|
|
230
|
+
if (!end.block) return pos;
|
|
231
|
+
const $pos = tr.doc.resolve(pos);
|
|
232
|
+
if ($pos.depth && $pos.node(1).attrs.id === end.block.id) return pos;
|
|
233
|
+
const left = end.left ? Y.createAbsolutePositionFromRelativePosition(end.left, pd.ydoc) : null;
|
|
234
|
+
if (!hiddenUnit(abs.index) && !(left && hiddenUnit(left.index - 1))) return pos;
|
|
235
|
+
return placeIn(end.block.id, end.block.offset) ?? pos;
|
|
236
|
+
};
|
|
237
|
+
const anchor = at(keep.anchor), head = at(keep.head);
|
|
238
|
+
if (anchor !== null && head !== null) tr.setSelection(pmState.TextSelection.create(tr.doc, anchor, head));
|
|
239
|
+
} else if (d.tables && view.state.selection instanceof pmState.TextSelection) {
|
|
240
|
+
const anchor = cellPoint(view.state.doc, view.state.selection.anchor), head = cellPoint(view.state.doc, view.state.selection.head);
|
|
241
|
+
const a = anchor && cellAt(tr.doc, anchor), h = head && cellAt(tr.doc, head);
|
|
242
|
+
if (a !== null && a !== undefined && h !== null && h !== undefined) tr.setSelection(pmState.TextSelection.create(tr.doc, a, h));
|
|
243
|
+
}
|
|
244
|
+
view.dispatch(tr);
|
|
245
|
+
};
|
|
246
|
+
const onBefore = (t) => {
|
|
247
|
+
if (!view || t.origin === LOCAL || t.origin === um || saved) return;
|
|
248
|
+
const sel = view.state.selection;
|
|
249
|
+
if (!(sel instanceof pmState.TextSelection)) return;
|
|
250
|
+
if ([sel.$anchor, sel.$head].some((pos) => pos.depth && pos.node(1).type.name === "table")) return;
|
|
251
|
+
const end = ($pos) => {
|
|
252
|
+
const index = yIndexAt(view.state.doc, w.holes, $pos.pos, w.visible);
|
|
253
|
+
return {
|
|
254
|
+
right: Y.createRelativePositionFromTypeIndex(pd.flow, index),
|
|
255
|
+
left: index > 0 ? Y.createRelativePositionFromTypeIndex(pd.flow, index, -1) : null,
|
|
256
|
+
block: $pos.depth ? { id: $pos.node(1).attrs.id, offset: $pos.parentOffset } : null,
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
saved = { anchor: end(sel.$anchor), head: end(sel.$head) };
|
|
260
|
+
};
|
|
261
|
+
const onAfter = (t) => {
|
|
262
|
+
if (!view) { bumpRecovery(); return; }
|
|
263
|
+
if (t.origin === LOCAL) { saved = null; return; }
|
|
264
|
+
render(t.origin === um);
|
|
265
|
+
};
|
|
266
|
+
const portMove = (tr, move) => {
|
|
267
|
+
const result = moveBlocks({ pd, view, um, origins, render }, move);
|
|
268
|
+
if (result.status === "refused") {
|
|
269
|
+
stats.moveFallbacks = (stats.moveFallbacks ?? 0) + 1;
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const sel = tr.selection;
|
|
273
|
+
if (sel instanceof pmState.TextSelection) return true;
|
|
274
|
+
const doc = view.state.doc;
|
|
275
|
+
let from = null, to = null;
|
|
276
|
+
doc.forEach((node, pos) => {
|
|
277
|
+
if (!move.ids.includes(node.attrs.id)) return;
|
|
278
|
+
if (from === null) from = pos;
|
|
279
|
+
to = pos + node.nodeSize;
|
|
280
|
+
});
|
|
281
|
+
if (from === null) return true;
|
|
282
|
+
const next = sel instanceof pmState.NodeSelection
|
|
283
|
+
? pmState.NodeSelection.create(doc, from)
|
|
284
|
+
: sel.anchor <= sel.head ? new BlockSelection(doc.resolve(from), doc.resolve(to)) : new BlockSelection(doc.resolve(to), doc.resolve(from));
|
|
285
|
+
const reselect = view.state.tr.setSelection(next).setMeta(REMOTE_META, true).setMeta("addToHistory", false);
|
|
286
|
+
view.dispatch(tr.scrolledIntoView ? reselect.scrollIntoView() : reselect);
|
|
287
|
+
return true;
|
|
288
|
+
};
|
|
289
|
+
const board = createBoardCommands({ pd, um, origins, render: () => (view ? render() : refresh()) });
|
|
290
|
+
pd.ydoc.on("beforeTransaction", onBefore);
|
|
291
|
+
pd.ydoc.on("afterTransaction", onAfter);
|
|
292
|
+
return {
|
|
293
|
+
pd, stats, render, um, board,
|
|
294
|
+
attach(v) {
|
|
295
|
+
view = v;
|
|
296
|
+
w = walk(pd);
|
|
297
|
+
},
|
|
298
|
+
dispose() {
|
|
299
|
+
pd.ydoc.off("beforeTransaction", onBefore);
|
|
300
|
+
pd.ydoc.off("afterTransaction", onAfter);
|
|
301
|
+
offOrigins();
|
|
302
|
+
origins.dispose();
|
|
303
|
+
um.destroy();
|
|
304
|
+
recoveryListeners.clear();
|
|
305
|
+
view = null;
|
|
306
|
+
},
|
|
307
|
+
get walk() { return w; },
|
|
308
|
+
get recoverable() { return recoveryState().recoverable; },
|
|
309
|
+
get notices() { return recoveryState().notices; },
|
|
310
|
+
subscribeRecovery(listener) {
|
|
311
|
+
if (typeof listener !== "function") throw new TypeError("subscribeRecovery needs a listener");
|
|
312
|
+
recoveryListeners.add(listener);
|
|
313
|
+
const snapshot = recoveryState();
|
|
314
|
+
if (recoverySignature === null) recoverySignature = recoverySignatureOf(snapshot);
|
|
315
|
+
listener(snapshot);
|
|
316
|
+
return () => { recoveryListeners.delete(listener); };
|
|
317
|
+
},
|
|
318
|
+
dismiss(sourceKey, revision) {
|
|
319
|
+
dismissed.add(`${sourceKey}\u0000${revision}`);
|
|
320
|
+
bumpRecovery();
|
|
321
|
+
},
|
|
322
|
+
dispatch(tr) {
|
|
323
|
+
const move = tr.getMeta(MOVE);
|
|
324
|
+
if (move && portMove(tr, move)) return;
|
|
325
|
+
const { state, transactions } = view.state.applyTransaction(tr);
|
|
326
|
+
const local = transactions.filter((t) => t.docChanged && !t.getMeta(REMOTE_META));
|
|
327
|
+
view.updateState(state);
|
|
328
|
+
if (!local.length) return;
|
|
329
|
+
w = walk(pd);
|
|
330
|
+
const history = transactions.every((t) => t.getMeta("addToHistory") !== false);
|
|
331
|
+
const register = registerAuthor;
|
|
332
|
+
registerAuthor = null;
|
|
333
|
+
localGroup(pd, local, w, stats, um, history, register);
|
|
334
|
+
const derived = refresh();
|
|
335
|
+
if (!sameBareShape(derived, view.state.doc)) {
|
|
336
|
+
stats.mismatch++;
|
|
337
|
+
stats.first ??= { before: bareShape(local[0].before), pm: bareShape(view.state.doc), y: bareShape(derived) };
|
|
338
|
+
}
|
|
339
|
+
render(false, derived);
|
|
340
|
+
},
|
|
341
|
+
// Restore inserts the displaced source as a normal block found by its Yjs identity, with no
|
|
342
|
+
// provenance layer. Four edge cases are accepted because they are rare and lose no text
|
|
343
|
+
// (David, 2026-10-21): two people restoring one source at once with different formatting
|
|
344
|
+
// changes keep one person's formatting; undoing a Restore after a peer typed into it joins
|
|
345
|
+
// the peer's text onto the previous block; a formatting-only change to a moved block is not
|
|
346
|
+
// offered for recovery, only typed text is; a personal notice can name an author whose text
|
|
347
|
+
// someone else removed later. The root fix is the provenance design this replaced
|
|
348
|
+
// (e4-recovery-spec.md): keep each unit's original interval and each editor's independent
|
|
349
|
+
// intentions per source, and re-expose the removed marker in place as a virtual position
|
|
350
|
+
// instead of inserting a new block. Concurrent Restores then merge formatting intentions,
|
|
351
|
+
// Undo re-hides exactly the restored units, a mark-only change becomes an intention the
|
|
352
|
+
// list can offer, and a notice checks the surviving intervals before it names anyone.
|
|
353
|
+
restore(sourceKey, revision) {
|
|
354
|
+
const fresh = walk(pd, origins);
|
|
355
|
+
const record = reduceRecovery(pd, { index: origins, walked: fresh }).sources.get(sourceKey);
|
|
356
|
+
const visible = fresh.visible.find((seg) => seg.sourceKey === sourceKey || (seg.brk && physicalKeyOf(seg.brk) === sourceKey));
|
|
357
|
+
if (visible) return { status: "resolved", id: visible.id };
|
|
358
|
+
if (!record || record.revision !== revision) {
|
|
359
|
+
const notice = recoveryState().notices.find((candidate) => candidate.source === sourceKey) ?? null;
|
|
360
|
+
return { status: "stale", notice };
|
|
361
|
+
}
|
|
362
|
+
const blockId = `${RECOVERED_PREFIX}${sourceKey}`;
|
|
363
|
+
um.stopCapturing();
|
|
364
|
+
let marker = null;
|
|
365
|
+
pd.ydoc.transact(() => {
|
|
366
|
+
pd.gone.set(restoreKey(sourceKey, mintOp(pd)), { covers: record.removals.map((removal) => removal.key), baseline: { type: record.type, attrs: JSON.parse(JSON.stringify(record.attrs)) } });
|
|
367
|
+
pd.flow.insertEmbed(restoreIndexAt(pd, sourceKey), { b: blockId });
|
|
368
|
+
marker = newestLocalMarker(pd, blockId);
|
|
369
|
+
origins.recordBoundary(marker, { blockId, kind: "restore", recoveryOf: sourceKey });
|
|
370
|
+
}, LOCAL);
|
|
371
|
+
um.stopCapturing();
|
|
372
|
+
render();
|
|
373
|
+
if (!w.visible.some((seg) => seg.sourceKey === sourceKey && seg.id === blockId)) throw new Error(`Restore did not project ${blockId}`);
|
|
374
|
+
return { status: "restored", id: blockId };
|
|
375
|
+
},
|
|
376
|
+
moveBlocks(args) {
|
|
377
|
+
return moveBlocks({ pd, view, um, origins, render }, args);
|
|
378
|
+
},
|
|
379
|
+
undo() { return um.undo() !== null; },
|
|
380
|
+
redo() { return um.redo() !== null; },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cloneJson } from "../../live/ops.js";
|
|
2
|
+
import { canon } from "../projection.js";
|
|
3
|
+
import { migrateHybridValues, YMigrationError } from "./migrate.js";
|
|
4
|
+
|
|
5
|
+
const PAGE = /^page(\.[0-9]+)?$/;
|
|
6
|
+
|
|
7
|
+
export async function prepareHybridConversion(tags) {
|
|
8
|
+
try {
|
|
9
|
+
if (!Array.isArray(tags) || !tags.length) throw new YMigrationError("Expected named hybrid page tags");
|
|
10
|
+
const names = new Set();
|
|
11
|
+
const source = tags.map((tag) => {
|
|
12
|
+
if (!Array.isArray(tag) || tag.length !== 2 || typeof tag[0] !== "string" || !PAGE.test(tag[0]) || names.has(tag[0])) {
|
|
13
|
+
throw new YMigrationError("Expected unique page tag names");
|
|
14
|
+
}
|
|
15
|
+
names.add(tag[0]);
|
|
16
|
+
return [tag[0], cloneJson(tag[1])];
|
|
17
|
+
});
|
|
18
|
+
const value = migrateHybridValues(source.map(([, value]) => value));
|
|
19
|
+
const bytes = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canon(source)));
|
|
20
|
+
const baseline = Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
21
|
+
return { baseline, value };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof YMigrationError) throw error;
|
|
24
|
+
throw new YMigrationError(`Cannot prepare hybrid conversion: ${error.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as Y from "../../vendor/yjs.vendor.js";
|
|
2
|
+
import { schema } from "../schema.js";
|
|
3
|
+
import { markKey } from "../inline.js";
|
|
4
|
+
import { ATOM, BREAK, DEFAULTS, blocksOfValues, runsOf } from "../projection.js";
|
|
5
|
+
import { createPageDoc, attrKeys, defaultOf, sameJson, PAGE_ROOTS } from "./layout.js";
|
|
6
|
+
import { resolveBoardFields } from "./board.js";
|
|
7
|
+
import { resolveTableFields } from "./table.js";
|
|
8
|
+
import { markName } from "./marks.js";
|
|
9
|
+
import { walk } from "./walk.js";
|
|
10
|
+
import { docFromValue, YDecodeError } from "./provider.js";
|
|
11
|
+
import { parseGoneIntents, parsePhysicalKey, RecoveryError } from "./recovery.js";
|
|
12
|
+
|
|
13
|
+
const copy = (value) => value && typeof value === "object" ? JSON.parse(JSON.stringify(value)) : value;
|
|
14
|
+
const unsupported = (message) => { throw new YDecodeError("unsupported-content", message); };
|
|
15
|
+
const hasContent = (root) => {
|
|
16
|
+
if (root._map) for (const item of root._map.values()) if (!item.deleted) return true;
|
|
17
|
+
for (let item = root._start; item; item = item.right) if (!item.deleted) return true;
|
|
18
|
+
return false;
|
|
19
|
+
};
|
|
20
|
+
const invalidOrigin = (message) => { throw new YDecodeError("invalid-content", message); };
|
|
21
|
+
const ORIGIN_FIELDS = { span: ["length", "sourceClient", "sourceClock"], alias: ["sourceClient", "sourceClock"], boundary: ["blockId", "kind", "recoveryOf"] };
|
|
22
|
+
const BOUNDARY_KINDS = ["copy", "removed", "restore"];
|
|
23
|
+
const isClock = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
24
|
+
const isPlain = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
const originId = (client, clock) => `${client}.${clock}`;
|
|
26
|
+
const boundaryIdentity = (record) => JSON.stringify([record.blockId, record.kind, record.recoveryOf ?? null]);
|
|
27
|
+
|
|
28
|
+
function validateFlow(pd) {
|
|
29
|
+
if (pd.flow._map.size) unsupported("The page flow must contain text and block markers");
|
|
30
|
+
for (let it = pd.flow._start; it; it = it.right) {
|
|
31
|
+
if (it.deleted) continue;
|
|
32
|
+
const c = it.content;
|
|
33
|
+
if (c instanceof Y.ContentString) continue;
|
|
34
|
+
if (c instanceof Y.ContentFormat) {
|
|
35
|
+
if (c.value !== null && !Object.hasOwn(schema.marks, markName(c.key))) unsupported(`Unsupported page mark: ${c.key}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c instanceof Y.ContentEmbed) {
|
|
39
|
+
const e = c.embed;
|
|
40
|
+
if (e && Object.keys(e).length === 1 && ((typeof e.b === "string" && e.b) || e.br === 1)) continue;
|
|
41
|
+
}
|
|
42
|
+
unsupported("Unsupported content in the page flow");
|
|
43
|
+
}
|
|
44
|
+
for (const [id, attrs] of pd.attrs) {
|
|
45
|
+
if (!(attrs instanceof Y.Map)) unsupported(`Unsupported attributes for page block: ${id}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const inFlow = (pd, key) => {
|
|
50
|
+
const id = typeof key === "string" ? parsePhysicalKey(key) : key;
|
|
51
|
+
if (!id) return false;
|
|
52
|
+
let item;
|
|
53
|
+
try { item = Y.getItem(pd.ydoc.store, id); } catch { return false; }
|
|
54
|
+
return item instanceof Y.Item && item.parent === pd.flow && item.id.clock <= id.clock && id.clock < item.id.clock + item.length;
|
|
55
|
+
};
|
|
56
|
+
const coverSource = (cover) => cover.charCodeAt(0) === 91 ? JSON.parse(cover)[1] : cover;
|
|
57
|
+
function validateRecovery(pd) {
|
|
58
|
+
let intents;
|
|
59
|
+
try {
|
|
60
|
+
intents = parseGoneIntents(pd.gone, { strict: true });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof RecoveryError) throw new YDecodeError("invalid-content", error.message);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
for (const record of intents.textDeletes) {
|
|
66
|
+
const item = Y.getItem(pd.ydoc.store, { client: record.client, clock: record.clock });
|
|
67
|
+
if (!(item instanceof Y.Item) || item.parent !== pd.flow || item.id.clock > record.clock || record.clock + record.length > item.id.clock + item.length) {
|
|
68
|
+
throw new YDecodeError("invalid-content", `Invalid text-delete record: ${record.key}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const invalid = (message) => { throw new YDecodeError("invalid-content", message); };
|
|
72
|
+
for (const removal of intents.removals) {
|
|
73
|
+
if (!inFlow(pd, removal.source)) invalid(`Invalid removal source: ${removal.key}`);
|
|
74
|
+
if (removal.end !== null && !inFlow(pd, removal.end)) invalid(`Invalid removal end: ${removal.key}`);
|
|
75
|
+
}
|
|
76
|
+
for (const restore of intents.restores) {
|
|
77
|
+
if (!inFlow(pd, restore.sourceKey)) invalid(`Invalid restore source: ${restore.key}`);
|
|
78
|
+
for (const cover of restore.covers) if (!inFlow(pd, coverSource(cover))) invalid(`Invalid restore cover: ${restore.key}`);
|
|
79
|
+
}
|
|
80
|
+
for (const key of intents.copyActive) if (!inFlow(pd, key)) invalid(`Invalid copy-active record: ${key}`);
|
|
81
|
+
for (const key of intents.attrs.keys()) if (!inFlow(pd, key)) invalid(`Invalid attribute override: ${key}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateOrigins(pd) {
|
|
85
|
+
const aliases = new Map();
|
|
86
|
+
const boundaries = new Map();
|
|
87
|
+
const fieldsOf = (value, allowed, key) => {
|
|
88
|
+
if (!isPlain(value)) invalidOrigin(`Invalid origin record: ${key}`);
|
|
89
|
+
for (const field of Object.keys(value)) if (!allowed.includes(field)) invalidOrigin(`Unsupported origin field ${field} in ${key}`);
|
|
90
|
+
};
|
|
91
|
+
for (const [key, value] of pd.origins) {
|
|
92
|
+
if (typeof key !== "string" || key.charCodeAt(0) !== 91) invalidOrigin(`Malformed origin key: ${key}`);
|
|
93
|
+
let parts = null;
|
|
94
|
+
try { parts = JSON.parse(key); } catch { invalidOrigin(`Malformed origin key: ${key}`); }
|
|
95
|
+
if (!Array.isArray(parts) || typeof parts[0] !== "string") invalidOrigin(`Malformed origin key: ${key}`);
|
|
96
|
+
const kind = parts[0];
|
|
97
|
+
if (kind === "author") {
|
|
98
|
+
if (parts.length !== 2 || !isClock(parts[1])) invalidOrigin(`Malformed origin key: ${key}`);
|
|
99
|
+
if (typeof value !== "string" || !value) invalidOrigin(`Invalid author record: ${key}`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!Object.hasOwn(ORIGIN_FIELDS, kind)) unsupported(`Unsupported origin record: ${key}`);
|
|
103
|
+
if (parts.length !== 3 || !isClock(parts[1]) || !isClock(parts[2])) invalidOrigin(`Malformed origin key: ${key}`);
|
|
104
|
+
fieldsOf(value, ORIGIN_FIELDS[kind], key);
|
|
105
|
+
if (kind === "span") {
|
|
106
|
+
if (!Number.isSafeInteger(value.length) || value.length < 1 || !isClock(value.sourceClient) || !isClock(value.sourceClock) || !isClock(value.sourceClock + value.length - 1)) invalidOrigin(`Invalid span record: ${key}`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (kind === "alias") {
|
|
110
|
+
if (!isClock(value.sourceClient) || !isClock(value.sourceClock)) invalidOrigin(`Invalid alias record: ${key}`);
|
|
111
|
+
aliases.set(originId(parts[1], parts[2]), { client: value.sourceClient, clock: value.sourceClock });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (typeof value.blockId !== "string" || !value.blockId || !BOUNDARY_KINDS.includes(value.kind)) invalidOrigin(`Invalid boundary record: ${key}`);
|
|
115
|
+
if (value.recoveryOf !== undefined && !parsePhysicalKey(value.recoveryOf)) invalidOrigin(`Invalid boundary record: ${key}`);
|
|
116
|
+
boundaries.set(originId(parts[1], parts[2]), value);
|
|
117
|
+
}
|
|
118
|
+
const claimed = new Map();
|
|
119
|
+
for (const [key, record] of boundaries) {
|
|
120
|
+
let at = key;
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
while (!seen.has(at)) {
|
|
123
|
+
seen.add(at);
|
|
124
|
+
const alias = aliases.get(at);
|
|
125
|
+
if (!alias) break;
|
|
126
|
+
at = originId(alias.client, alias.clock);
|
|
127
|
+
}
|
|
128
|
+
if (seen.has(at) && aliases.has(at)) invalidOrigin(`Cyclic boundary alias: ${key}`);
|
|
129
|
+
const previous = claimed.get(at);
|
|
130
|
+
if (previous && boundaryIdentity(previous) !== boundaryIdentity(record)) invalidOrigin(`Conflicting boundary values: ${key}`);
|
|
131
|
+
claimed.set(at, record);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function blockOf(pd, block, index) {
|
|
136
|
+
if (!Object.hasOwn(DEFAULTS, block.t)) unsupported(`Unsupported page block type: ${block.t}`);
|
|
137
|
+
const type = schema.nodes[block.t];
|
|
138
|
+
const attrs = pd.attrs.get(block.baseId);
|
|
139
|
+
const table = block.t === "table" && !block.fields ? resolveTableFields(pd.tableFields, block.baseId, { cols: attrs?.get("cols"), rows: attrs?.get("rows") }) : null;
|
|
140
|
+
const out = { t: block.t, pos: String(index + 1).padStart(8, "0") };
|
|
141
|
+
for (const key of attrKeys(type)) {
|
|
142
|
+
if (block.t === "board" && (key === "columns" || key === "cards")) continue;
|
|
143
|
+
const value = block.fields ? block.fields[key] : table && (key === "cols" || key === "rows") ? table[key] : attrs?.has(key) ? attrs.get(key) : defaultOf(type, key);
|
|
144
|
+
if (!sameJson(value, defaultOf(type, key))) out[key] = copy(value);
|
|
145
|
+
}
|
|
146
|
+
if (block.t === "table") out.rows = copy((block.fields ? block.fields.rows : table.rows) ?? {});
|
|
147
|
+
if (block.t === "board") Object.assign(out, block.fields ? { columns: copy(block.fields.columns ?? {}), cards: copy(block.fields.cards ?? {}) } : resolveBoardFields(pd.boardFields, block.baseId));
|
|
148
|
+
if (type.isTextblock) {
|
|
149
|
+
let text = "";
|
|
150
|
+
const units = [];
|
|
151
|
+
for (const run of block.runs) {
|
|
152
|
+
if (run.hole) continue;
|
|
153
|
+
if (run.br) {
|
|
154
|
+
if (block.t === "code_block") unsupported("A code block cannot contain a hard-break atom");
|
|
155
|
+
text += ATOM;
|
|
156
|
+
units.push(BREAK);
|
|
157
|
+
} else {
|
|
158
|
+
text += run.text;
|
|
159
|
+
const key = markKey([...run.marks].sort((a, b) => a.type.rank - b.type.rank));
|
|
160
|
+
for (let i = 0; i < run.text.length; i++) units.push(key);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
out.c = text;
|
|
164
|
+
if (units.some(Boolean)) out.m = runsOf(units);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function blocksFromYValue(value) {
|
|
170
|
+
const doc = docFromValue(value, { strict: true });
|
|
171
|
+
try {
|
|
172
|
+
for (const [name, root] of doc.share) if (!PAGE_ROOTS.includes(name) && hasContent(root)) unsupported(`Unsupported page root: ${name}`);
|
|
173
|
+
const pd = createPageDoc(doc);
|
|
174
|
+
validateFlow(pd);
|
|
175
|
+
resolveBoardFields(pd.boardFields);
|
|
176
|
+
resolveTableFields(pd.tableFields);
|
|
177
|
+
validateRecovery(pd);
|
|
178
|
+
validateOrigins(pd);
|
|
179
|
+
const visible = walk(pd).visible;
|
|
180
|
+
const reserved = new Set(visible.filter((block) => !block.implicit).map((block) => block.id));
|
|
181
|
+
const used = new Set();
|
|
182
|
+
return Object.fromEntries(visible.map((block, index) => {
|
|
183
|
+
let id = block.id;
|
|
184
|
+
if (used.has(id) || (block.implicit && reserved.has(id))) {
|
|
185
|
+
let copy = 2;
|
|
186
|
+
do { id = `${block.id}~${copy++}`; } while (used.has(id) || reserved.has(id));
|
|
187
|
+
}
|
|
188
|
+
used.add(id);
|
|
189
|
+
return [id, blockOf(pd, block, index)];
|
|
190
|
+
}));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error instanceof YDecodeError) throw error;
|
|
193
|
+
throw new YDecodeError("invalid-content", `Invalid Yjs page content: ${error.message}`);
|
|
194
|
+
} finally {
|
|
195
|
+
doc.destroy();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function blocksFromPageValues(values) {
|
|
200
|
+
if (values.some((value) => !value || typeof value !== "object" || Array.isArray(value))) {
|
|
201
|
+
throw new YDecodeError("invalid-value", "Expected a page data object");
|
|
202
|
+
}
|
|
203
|
+
if (!values.some((value) => Object.hasOwn(value, "y") || Object.hasOwn(value, "v"))) return blocksOfValues(values);
|
|
204
|
+
if (values.length !== 1 || Object.hasOwn(values[0], "blocks") || Object.hasOwn(values[0], "deleted")) {
|
|
205
|
+
throw new YDecodeError("invalid-value", "A Yjs page must have one page tag without hybrid block data");
|
|
206
|
+
}
|
|
207
|
+
return blocksFromYValue(values[0]);
|
|
208
|
+
}
|