@panphora/clayjs 1.13.1 → 1.15.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.
Files changed (146) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69385 -40992
  3. package/dist/server/checkpoint.cjs +1 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/core.cjs +72210 -0
  6. package/dist/server/rooms.cjs +1 -0
  7. package/dist/server/rooms.mjs +66 -0
  8. package/docs/guide.md +127 -99
  9. package/entries/clay.js +22 -0
  10. package/entries/sap.js +5 -5
  11. package/package.json +16 -2
  12. package/packed-contract.json +18 -3
  13. package/src/core/boot-phase.js +6 -0
  14. package/src/core/conflict-download.js +12 -3
  15. package/src/core/host-meta.js +75 -0
  16. package/src/core/persist.js +11 -0
  17. package/src/core/save-core.js +266 -31
  18. package/src/core/save.js +154 -36
  19. package/src/core/snapshot.js +51 -13
  20. package/src/core/style-comparisons.js +37 -0
  21. package/src/dom/insert-style-tag.js +11 -12
  22. package/src/lib/derived.js +116 -0
  23. package/src/lib/effect.js +130 -0
  24. package/src/lib/mutation.js +3 -1
  25. package/src/lib/style-adoption.js +9 -0
  26. package/src/lib/style.js +47 -0
  27. package/src/live/fast-lane.js +347 -60
  28. package/src/live/local-lane.js +32 -6
  29. package/src/live/ops.js +77 -0
  30. package/src/live/provenance.js +129 -0
  31. package/src/live/save-history.js +188 -0
  32. package/src/live/server-lane.js +9 -2
  33. package/src/live/store.js +194 -16
  34. package/src/loader-logic.js +16 -4
  35. package/src/loader.js +37 -1
  36. package/src/page/address.js +20 -0
  37. package/src/page/adopt-boot.js +189 -0
  38. package/src/page/adopt.js +240 -0
  39. package/src/page/ai-tool.js +188 -0
  40. package/src/page/announce.js +21 -0
  41. package/src/page/blocks.js +1170 -0
  42. package/src/page/blocksel.js +269 -0
  43. package/src/page/board-drag.js +226 -0
  44. package/src/page/board-panel.js +353 -0
  45. package/src/page/board-style.js +146 -0
  46. package/src/page/board-view.js +447 -0
  47. package/src/page/board.js +41 -0
  48. package/src/page/chart.js +220 -0
  49. package/src/page/checklist.js +143 -0
  50. package/src/page/chrome.js +587 -0
  51. package/src/page/csv-import.js +169 -0
  52. package/src/page/drag.js +202 -0
  53. package/src/page/drop.js +112 -0
  54. package/src/page/editor.js +386 -0
  55. package/src/page/highlight.js +268 -0
  56. package/src/page/inline.js +60 -1
  57. package/src/page/items.js +159 -0
  58. package/src/page/link.js +247 -0
  59. package/src/page/markdown.js +71 -0
  60. package/src/page/media.js +570 -0
  61. package/src/page/merge.js +130 -0
  62. package/src/page/model.js +71 -9
  63. package/src/page/placeholder.js +23 -0
  64. package/src/page/projection.js +647 -0
  65. package/src/page/recover.js +193 -0
  66. package/src/page/rich/cell.js +16 -0
  67. package/src/page/rich/chart.js +104 -0
  68. package/src/page/rich/csv.js +118 -0
  69. package/src/page/rich/page-tool.js +86 -0
  70. package/src/page/rich/tool-runtime.js +195 -0
  71. package/src/page/save-projection.js +53 -0
  72. package/src/page/save-states.js +85 -0
  73. package/src/page/schema.js +12 -5
  74. package/src/page/table-kinds.js +20 -0
  75. package/src/page/table-paste.js +57 -0
  76. package/src/page/table-select.js +244 -0
  77. package/src/page/table-style.js +25 -0
  78. package/src/page/table.js +650 -0
  79. package/src/page/themes/content.css +46 -0
  80. package/src/page/themes/light-1.css +269 -0
  81. package/src/page/themes/light-2.css +211 -0
  82. package/src/page/themes/light-3.css +130 -0
  83. package/src/page/themes/light-4.css +299 -0
  84. package/src/page/tool-data.js +25 -0
  85. package/src/page/tool-view.js +68 -0
  86. package/src/page/tools.js +133 -0
  87. package/src/page/view.js +136 -0
  88. package/src/page/ybind/author.js +25 -0
  89. package/src/page/ybind/binding.js +92 -0
  90. package/src/page/ybind/board-commands.js +170 -0
  91. package/src/page/ybind/board.js +89 -0
  92. package/src/page/ybind/bridge.js +382 -0
  93. package/src/page/ybind/conversion.js +26 -0
  94. package/src/page/ybind/core.js +7 -0
  95. package/src/page/ybind/decode.js +208 -0
  96. package/src/page/ybind/doc.js +58 -0
  97. package/src/page/ybind/external.js +190 -0
  98. package/src/page/ybind/group.js +124 -0
  99. package/src/page/ybind/index.js +12 -0
  100. package/src/page/ybind/layout.js +27 -0
  101. package/src/page/ybind/marks.js +18 -0
  102. package/src/page/ybind/migrate.js +152 -0
  103. package/src/page/ybind/moves.js +288 -0
  104. package/src/page/ybind/origins.js +257 -0
  105. package/src/page/ybind/parts.js +44 -0
  106. package/src/page/ybind/projection-map.js +92 -0
  107. package/src/page/ybind/provider.js +285 -0
  108. package/src/page/ybind/recovery.js +411 -0
  109. package/src/page/ybind/seed.js +27 -0
  110. package/src/page/ybind/table.js +166 -0
  111. package/src/page/ybind/tokens.js +93 -0
  112. package/src/page/ybind/walk.js +333 -0
  113. package/src/page/ybind/writer.js +307 -0
  114. package/src/plugins/derive.js +12 -0
  115. package/src/plugins/indicator.js +16 -6
  116. package/src/plugins/live.js +742 -65
  117. package/src/plugins/page.js +547 -0
  118. package/src/plugins/undo.js +62 -4
  119. package/src/server/checkpoint/index.js +218 -0
  120. package/src/server/rooms/index.js +59 -0
  121. package/src/server/rooms/live-data-ops.js +369 -0
  122. package/src/server/rooms/live-data-rooms.js +965 -0
  123. package/src/server/rooms/live-data-tags.js +49 -0
  124. package/src/server/rooms/live-page-history.js +41 -0
  125. package/src/server/rooms/live-provenance.js +142 -0
  126. package/src/server/rooms/page-admission.js +79 -0
  127. package/src/server/rooms/page-conversion.js +62 -0
  128. package/src/server/rooms/page-rebase.js +108 -0
  129. package/src/server/rooms/page-state.js +91 -0
  130. package/src/server/rooms/page-write-admission.js +63 -0
  131. package/src/server/rooms/page-write-guard.js +23 -0
  132. package/src/server/rooms/recent.js +18 -0
  133. package/src/server/rooms/spec-wire.js +17 -0
  134. package/src/standalone.js +22 -0
  135. package/src/sync/conflict-revert.js +2 -0
  136. package/src/sync/conflicts.js +2 -2
  137. package/src/sync/live-sync.js +36 -8
  138. package/src/sync/stream.js +62 -16
  139. package/src/ui/index.js +4 -0
  140. package/src/ui/toast.js +21 -5
  141. package/src/vendor/pierre/pierre.vendor.js +3 -0
  142. package/src/vendor/sap-core.vendor.js +9 -0
  143. package/src/vendor/sap-ownership.vendor.js +111 -0
  144. package/src/vendor/yjs.vendor.js +7 -0
  145. package/src/page/binding.js +0 -647
  146. package/src/page/replay.js +0 -578
@@ -0,0 +1,130 @@
1
+ import { canon, renderModel, parseModel, fragmentCleaner } from "./projection.js";
2
+
3
+ const TEXT = new Set(["paragraph", "heading", "list_item", "callout", "code_block", "toggle"]);
4
+ const same = (a, b) => canon(a) === canon(b);
5
+
6
+ function inlineOf(item, doc) {
7
+ const article = doc.createElement("article");
8
+ article.setAttribute("data-page", "");
9
+ article.innerHTML = renderModel([{ id: item.id, t: "paragraph", c: item.c ?? "", ...(item.m ? { m: item.m } : {}) }], { clean: fragmentCleaner(doc) });
10
+ return article;
11
+ }
12
+
13
+ // An incoming block whose id is not in base but whose content is the one base
14
+ // block of its kind that no other incoming block took: a writer that dropped the
15
+ // data-ids. It gets that block's id back, so the rest of the merge sees the edit
16
+ // rather than a deletion and an insertion. Scans base in order, from after the
17
+ // base item matched to the previous incoming item, and points the children of a
18
+ // block that got its id back at that id. Returns copies.
19
+ function restoreIds(base, incoming) {
20
+ const index = new Map(base.map((x, n) => [x.id, n]));
21
+ const used = new Set(incoming.map((x) => x.id));
22
+ const renamed = new Map();
23
+ let from = 0;
24
+ const out = incoming.map((x) => {
25
+ const at = index.get(x.id);
26
+ if (at !== undefined) {
27
+ from = at + 1;
28
+ return x;
29
+ }
30
+ const content = { c: x.c ?? "", m: x.m ?? null };
31
+ for (let k = from; k < base.length; k++) {
32
+ const y = base[k];
33
+ if (used.has(y.id) || y.t !== x.t || !same({ c: y.c ?? "", m: y.m ?? null }, content)) continue;
34
+ used.add(y.id);
35
+ from = k + 1;
36
+ renamed.set(x.id, y.id);
37
+ return { ...x, id: y.id };
38
+ }
39
+ return x;
40
+ });
41
+ return out.map((x) => (renamed.has(x.parent) ? { ...x, parent: renamed.get(x.parent) } : x));
42
+ }
43
+
44
+ // Three page models (arrays from parseModel) merged block by block: the
45
+ // structure and every field through mergeJson, and merge3 only inside a text
46
+ // block both sides changed. The same words changed on both sides go to the
47
+ // incoming side; a block one side edited and the other deleted is kept. A code
48
+ // block both sides changed keeps its incoming text, newlines and all, and
49
+ // conflicts: merge3 on it would flatten them.
50
+ // merge3 and mergeJson are passed in: the browser has the vendored
51
+ // HyperMorph, the server the hyper-morph package.
52
+ export function mergePageModels(base, current, incoming, { doc, merge3, mergeJson }) {
53
+ const conflicts = [];
54
+ const inc = restoreIds(base, incoming);
55
+ const b = new Map(base.map((x) => [x.id, x]));
56
+ const c = new Map(current.map((x) => [x.id, x]));
57
+ const i = new Map(inc.map((x) => [x.id, x]));
58
+ const current2 = current.map((x) => ({ ...x }));
59
+ const incoming2 = inc.map((x) => ({ ...x }));
60
+ const c2 = new Map(current2.map((x) => [x.id, x]));
61
+ const i2 = new Map(incoming2.map((x) => [x.id, x]));
62
+ for (const [id, was] of b) {
63
+ const l = c.get(id);
64
+ const r = i.get(id);
65
+ if (!l || !r || !TEXT.has(was.t) || !TEXT.has(l.t) || !TEXT.has(r.t)) continue;
66
+ const tb = { c: was.c ?? "", m: was.m ?? null };
67
+ const tl = { c: l.c ?? "", m: l.m ?? null };
68
+ const tr = { c: r.c ?? "", m: r.m ?? null };
69
+ if (same(tl, tb) || same(tr, tb) || same(tl, tr)) continue;
70
+ if (was.t === "code_block") {
71
+ for (const t of [c2.get(id), i2.get(id)]) {
72
+ t.c = r.c ?? "";
73
+ if (r.m) t.m = r.m;
74
+ else delete t.m;
75
+ }
76
+ conflicts.push({ id, kind: "text", local: l.c ?? "", remote: r.c ?? "" });
77
+ continue;
78
+ }
79
+ const res = merge3(inlineOf(was, doc), inlineOf(l, doc), inlineOf(r, doc));
80
+ for (const k of res.conflicts) conflicts.push({ id, kind: "text", local: k.local?.textContent ?? null, remote: k.remote?.textContent ?? null });
81
+ const [merged] = parseModel(res.root, { seed: "", known: new Map() });
82
+ for (const t of [c2.get(id), i2.get(id)]) {
83
+ t.c = merged.c;
84
+ if (merged.m) t.m = merged.m;
85
+ else delete t.m;
86
+ }
87
+ }
88
+ const order = current.map((x) => x.id);
89
+ for (const [id, was] of b) {
90
+ const l = c2.get(id);
91
+ if (!l || i.has(id) || same(c.get(id), was)) continue;
92
+ let k = order.indexOf(id) - 1;
93
+ while (k >= 0 && !i2.has(order[k])) k--;
94
+ const at = k < 0 ? 0 : incoming2.findIndex((x) => x.id === order[k]) + 1;
95
+ incoming2.splice(at, 0, { ...l });
96
+ i2.set(id, incoming2[at]);
97
+ conflicts.push({ id, kind: "kept-edit" });
98
+ }
99
+ const out = mergeJson(base, current2, incoming2, {
100
+ onConflict: (k) => conflicts.push({ kind: "field", path: k.path, local: k.local, remote: k.remote, resolved: k.resolved }),
101
+ });
102
+ const ids = new Set(out.map((x) => x.id));
103
+ const parentOf = (id) => (c.get(id) ?? b.get(id) ?? i.get(id))?.parent ?? null;
104
+ const fixed = out.map((x) => {
105
+ let p = x.parent ?? null;
106
+ while (p && !ids.has(p)) p = parentOf(p);
107
+ return p === (x.parent ?? null) ? x : { ...x, parent: p };
108
+ });
109
+ // Both sides moving blocks into each other leaves a parent cycle: the block
110
+ // whose incoming parent closes the loop keeps the tab's parent instead, and a
111
+ // loop that remains goes back to base.
112
+ const parents = new Map(fixed.map((x) => [x.id, x.parent ?? null]));
113
+ const loops = (id) => {
114
+ const seen = new Set([id]);
115
+ for (let p = parents.get(id); p; p = parents.get(p)) {
116
+ if (seen.has(p)) return true;
117
+ seen.add(p);
118
+ }
119
+ return false;
120
+ };
121
+ for (const x of fixed) {
122
+ if (!loops(x.id)) continue;
123
+ for (const p of [c.get(x.id)?.parent, b.get(x.id)?.parent, null]) {
124
+ parents.set(x.id, p && ids.has(p) ? p : null);
125
+ if (!loops(x.id)) break;
126
+ }
127
+ }
128
+ const items = fixed.map((x) => ((x.parent ?? null) === parents.get(x.id) ? x : { ...x, parent: parents.get(x.id) }));
129
+ return { items, conflicts };
130
+ }
package/src/page/model.js CHANGED
@@ -5,14 +5,15 @@
5
5
  // text and `m` its mark keys, one per UTF-16 unit, left out when there are none
6
6
  // (inline.js). Attributes equal to their schema default are left out, so
7
7
  // a block's JSON is as small as the block. A table keeps its rows as an object
8
- // keyed by row id ({ pos, cells: { <col id>: text } }), never a list, because a
8
+ // keyed by row id ({ pos, cells: { <col id>: cell } }), never a list, because a
9
9
  // list in a live store is written whole and two tabs adding rows keep one.
10
10
  //
11
11
  // Order is (pos, id): pos strings compare as plain strings, and the id breaks
12
12
  // the tie two tabs inserting at the same spot leave behind. Ids and positions
13
13
  // are only ever assigned by a local edit (identityPlugin), never on open.
14
14
  import { model, state, transform } from "../vendor/prosemirror.vendor.js";
15
- import { flatten, unflatten } from "./inline.js";
15
+ import { flatten, unflatten, unitsOf, runsOf } from "./inline.js";
16
+ import { cellText } from "./rich/cell.js";
16
17
 
17
18
  export const REMOTE = "clay-page-remote";
18
19
 
@@ -85,13 +86,29 @@ function attrsJson(node) {
85
86
  return out;
86
87
  }
87
88
 
89
+ // A table cell as the record keeps it: its text when it has no marks and no
90
+ // line breaks, otherwise { c, m } in the text blocks' inline encoding.
91
+ export function cellValue(cell) {
92
+ const { c, m } = flatten(cell.content);
93
+ return m.some(Boolean) ? { c, m: runsOf(m) } : c;
94
+ }
95
+
96
+ export function cellContent(schema, value) {
97
+ if (value !== null && typeof value === "object" && typeof value.c === "string") {
98
+ const units = unitsOf(value.m, value.c.length);
99
+ if (units) return unflatten(schema, value.c, units, schema.nodes.table_cell);
100
+ }
101
+ const text = cellText(value);
102
+ return text ? schema.text(text) : null;
103
+ }
104
+
88
105
  export function blockJson(node) {
89
106
  const out = { t: node.type.name, pos: node.attrs.pos, ...attrsJson(node) };
90
107
  if (node.type.name === "table") {
91
108
  const rows = {};
92
109
  node.forEach((row) => {
93
110
  const cells = {};
94
- row.forEach((cell) => { cells[cell.attrs.col] = cell.textContent; });
111
+ row.forEach((cell) => { cells[cell.attrs.col] = cellValue(cell); });
95
112
  rows[row.attrs.id] = { pos: row.attrs.pos, cells };
96
113
  });
97
114
  out.rows = rows;
@@ -116,17 +133,14 @@ export function nodeFromBlock(schema, id, blockValue) {
116
133
  const { table_row: rowType, table_cell: cellType } = schema.nodes;
117
134
  const children = orderedIds(rows || {}).map((rid) => rowType.createChecked(
118
135
  { id: rid, pos: rows[rid].pos },
119
- cols.map((col) => {
120
- const text = rows[rid].cells?.[col];
121
- return cellType.createChecked({ col }, text ? schema.text(String(text)) : null);
122
- }),
136
+ cols.map((col) => cellType.createChecked({ col }, cellContent(schema, rows[rid].cells?.[col]))),
123
137
  ));
124
138
  return schema.nodes.table.createChecked({ ...attrs, id, pos }, children);
125
139
  }
126
140
  const type = schema.nodes[t];
127
141
  if (!type) throw new RangeError(`page: unknown block type ${t}`);
128
142
  // A record from before the flat form holds ProseMirror's inline JSON.
129
- const content = typeof c === "string" ? unflatten(schema, c, m, type) : Array.isArray(c) && c.length ? model.Fragment.fromJSON(schema, c) : null;
143
+ const content = typeof c === "string" ? unflatten(schema, c, unitsOf(m, c.length), type) : Array.isArray(c) && c.length ? model.Fragment.fromJSON(schema, c) : null;
130
144
  return type.create({ ...attrs, id, pos }, content);
131
145
  }
132
146
 
@@ -198,10 +212,57 @@ export function repairSiblings(items, prefix) {
198
212
  return out;
199
213
  }
200
214
 
215
+ // True when every step of tr edits inside one top-level textblock: its text
216
+ // and marks change, never which blocks the page has, their order or attrs.
217
+ export function insideTextblocks(tr) {
218
+ for (let i = 0; i < tr.steps.length; i++) {
219
+ const step = tr.steps[i];
220
+ if (step instanceof transform.ReplaceStep) {
221
+ if (step.slice.openStart || step.slice.openEnd) return false;
222
+ for (let j = 0; j < step.slice.content.childCount; j++) if (!step.slice.content.child(j).isInline) return false;
223
+ } else if (!(step instanceof transform.AddMarkStep || step instanceof transform.RemoveMarkStep)) return false;
224
+ const $from = tr.docs[i].resolve(step.from), $to = tr.docs[i].resolve(step.to);
225
+ if ($from.depth !== 1 || $to.depth !== 1 || $from.index(0) !== $to.index(0) || !$from.parent.isTextblock) return false;
226
+ }
227
+ return true;
228
+ }
229
+
201
230
  export function identityPlugin() {
231
+ // Documents with nothing to repair. An edit inside textblocks keeps a
232
+ // document that way, so it skips the walk over every block.
233
+ const clean = new WeakSet();
234
+ const ready = (doc) => {
235
+ const ids = new Set();
236
+ let previous = null, valid = true;
237
+ doc.forEach((node) => {
238
+ if (!node.attrs.id || ids.has(node.attrs.id) || !isKey(node.attrs.pos) || (previous !== null && previous >= node.attrs.pos)) valid = false;
239
+ ids.add(node.attrs.id);
240
+ previous = node.attrs.pos;
241
+ if (node.type.name !== "table") return;
242
+ const rows = new Set();
243
+ let rowPrevious = null;
244
+ node.forEach((row) => {
245
+ if (!row.attrs.id || rows.has(row.attrs.id) || !isKey(row.attrs.pos) || (rowPrevious !== null && rowPrevious >= row.attrs.pos)) valid = false;
246
+ rows.add(row.attrs.id);
247
+ rowPrevious = row.attrs.pos;
248
+ });
249
+ });
250
+ return valid;
251
+ };
202
252
  return new state.Plugin({
253
+ state: {
254
+ init(_config, editor) { if (ready(editor.doc)) clean.add(editor.doc); return null; },
255
+ apply() { return null; },
256
+ },
203
257
  appendTransaction(transactions, old, next) {
204
- if (!transactions.some((tr) => tr.docChanged && !tr.getMeta(REMOTE))) return null;
258
+ if (clean.has(old.doc) && transactions.every(insideTextblocks)) {
259
+ clean.add(next.doc);
260
+ return null;
261
+ }
262
+ if (!transactions.some((tr) => tr.docChanged && !tr.getMeta(REMOTE))) {
263
+ if (clean.has(old.doc) && ready(next.doc)) clean.add(next.doc);
264
+ return null;
265
+ }
205
266
  const tr = next.tr;
206
267
  const fix = (node, at, change) => { if (change) tr.setNodeMarkup(at, null, { ...node.attrs, ...change }); };
207
268
  // Of two holders of an id, the one with the old block's content keeps it
@@ -235,6 +296,7 @@ export function identityPlugin() {
235
296
  node.forEach((row, rowOffset) => rows.push({ node: row, at: offset + 1 + rowOffset }));
236
297
  repairSiblings(items(rows), "r").forEach((change, i) => fix(rows[i].node, rows[i].at, change));
237
298
  }
299
+ if (!tr.docChanged) clean.add(next.doc);
238
300
  return tr.docChanged ? tr : null;
239
301
  },
240
302
  });
@@ -0,0 +1,23 @@
1
+ import { state, view as pmView } from "../vendor/prosemirror.vendor.js";
2
+ import { schema } from "./schema.js";
3
+
4
+ const { Decoration, DecorationSet } = pmView;
5
+
6
+ export const PLACEHOLDER_TEXT = "Type / for blocks";
7
+
8
+ export function placeholderPlugin(text = PLACEHOLDER_TEXT) {
9
+ return new state.Plugin({
10
+ props: {
11
+ decorations(st) {
12
+ const { selection } = st;
13
+ if (!selection.empty) return null;
14
+ const $from = selection.$from;
15
+ if ($from.depth !== 1) return null;
16
+ const node = $from.parent;
17
+ if (node.type !== schema.nodes.paragraph || node.content.size) return null;
18
+ const pos = $from.before(1);
19
+ return DecorationSet.create(st.doc, [Decoration.node(pos, pos + node.nodeSize, { class: "page-placeholder", "data-placeholder": text })]);
20
+ },
21
+ },
22
+ });
23
+ }