@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.
Files changed (136) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69667 -41708
  3. package/dist/server/checkpoint.cjs +70690 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/rooms.cjs +72039 -0
  6. package/dist/server/rooms.mjs +66 -0
  7. package/docs/guide.md +94 -93
  8. package/entries/clay.js +22 -0
  9. package/package.json +16 -2
  10. package/packed-contract.json +17 -3
  11. package/src/core/conflict-download.js +11 -2
  12. package/src/core/host-meta.js +75 -0
  13. package/src/core/persist.js +11 -0
  14. package/src/core/save-core.js +266 -31
  15. package/src/core/save.js +62 -32
  16. package/src/core/snapshot.js +39 -13
  17. package/src/core/style-comparisons.js +37 -0
  18. package/src/dom/insert-style-tag.js +11 -12
  19. package/src/lib/effect.js +130 -0
  20. package/src/lib/style-adoption.js +9 -0
  21. package/src/lib/style.js +44 -0
  22. package/src/live/fast-lane.js +347 -60
  23. package/src/live/local-lane.js +32 -6
  24. package/src/live/ops.js +77 -0
  25. package/src/live/provenance.js +129 -0
  26. package/src/live/save-history.js +188 -0
  27. package/src/live/server-lane.js +9 -2
  28. package/src/live/store.js +194 -16
  29. package/src/loader-logic.js +7 -3
  30. package/src/loader.js +6 -0
  31. package/src/page/address.js +20 -0
  32. package/src/page/adopt-boot.js +189 -0
  33. package/src/page/adopt.js +240 -0
  34. package/src/page/ai-tool.js +188 -0
  35. package/src/page/announce.js +21 -0
  36. package/src/page/blocks.js +1121 -0
  37. package/src/page/blocksel.js +269 -0
  38. package/src/page/board-drag.js +226 -0
  39. package/src/page/board-panel.js +353 -0
  40. package/src/page/board-style.js +146 -0
  41. package/src/page/board-view.js +447 -0
  42. package/src/page/board.js +41 -0
  43. package/src/page/chart.js +220 -0
  44. package/src/page/checklist.js +143 -0
  45. package/src/page/chrome.js +587 -0
  46. package/src/page/csv-import.js +169 -0
  47. package/src/page/drag.js +202 -0
  48. package/src/page/drop.js +112 -0
  49. package/src/page/editor.js +384 -0
  50. package/src/page/highlight.js +268 -0
  51. package/src/page/inline.js +60 -1
  52. package/src/page/items.js +159 -0
  53. package/src/page/link.js +247 -0
  54. package/src/page/markdown.js +71 -0
  55. package/src/page/media.js +570 -0
  56. package/src/page/merge.js +130 -0
  57. package/src/page/model.js +71 -9
  58. package/src/page/placeholder.js +23 -0
  59. package/src/page/projection.js +647 -0
  60. package/src/page/recover.js +193 -0
  61. package/src/page/rich/cell.js +16 -0
  62. package/src/page/rich/chart.js +104 -0
  63. package/src/page/rich/csv.js +118 -0
  64. package/src/page/rich/page-tool.js +86 -0
  65. package/src/page/rich/tool-runtime.js +195 -0
  66. package/src/page/save-projection.js +53 -0
  67. package/src/page/save-states.js +85 -0
  68. package/src/page/schema.js +12 -5
  69. package/src/page/table-kinds.js +20 -0
  70. package/src/page/table-paste.js +57 -0
  71. package/src/page/table-select.js +244 -0
  72. package/src/page/table-style.js +25 -0
  73. package/src/page/table.js +650 -0
  74. package/src/page/themes/content.css +46 -0
  75. package/src/page/themes/light-1.css +269 -0
  76. package/src/page/themes/light-2.css +211 -0
  77. package/src/page/themes/light-3.css +130 -0
  78. package/src/page/themes/light-4.css +299 -0
  79. package/src/page/tool-data.js +25 -0
  80. package/src/page/tool-view.js +68 -0
  81. package/src/page/tools.js +133 -0
  82. package/src/page/view.js +136 -0
  83. package/src/page/ybind/author.js +25 -0
  84. package/src/page/ybind/binding.js +92 -0
  85. package/src/page/ybind/board-commands.js +170 -0
  86. package/src/page/ybind/board.js +89 -0
  87. package/src/page/ybind/bridge.js +382 -0
  88. package/src/page/ybind/conversion.js +26 -0
  89. package/src/page/ybind/core.js +7 -0
  90. package/src/page/ybind/decode.js +208 -0
  91. package/src/page/ybind/doc.js +58 -0
  92. package/src/page/ybind/external.js +190 -0
  93. package/src/page/ybind/group.js +124 -0
  94. package/src/page/ybind/index.js +12 -0
  95. package/src/page/ybind/layout.js +27 -0
  96. package/src/page/ybind/marks.js +18 -0
  97. package/src/page/ybind/migrate.js +152 -0
  98. package/src/page/ybind/moves.js +288 -0
  99. package/src/page/ybind/origins.js +257 -0
  100. package/src/page/ybind/parts.js +44 -0
  101. package/src/page/ybind/projection-map.js +92 -0
  102. package/src/page/ybind/provider.js +285 -0
  103. package/src/page/ybind/recovery.js +411 -0
  104. package/src/page/ybind/seed.js +27 -0
  105. package/src/page/ybind/table.js +166 -0
  106. package/src/page/ybind/tokens.js +93 -0
  107. package/src/page/ybind/walk.js +333 -0
  108. package/src/page/ybind/writer.js +307 -0
  109. package/src/plugins/indicator.js +16 -6
  110. package/src/plugins/live.js +742 -65
  111. package/src/plugins/page.js +547 -0
  112. package/src/plugins/undo.js +60 -3
  113. package/src/server/checkpoint/index.js +218 -0
  114. package/src/server/rooms/index.js +59 -0
  115. package/src/server/rooms/live-data-ops.js +369 -0
  116. package/src/server/rooms/live-data-rooms.js +965 -0
  117. package/src/server/rooms/live-data-tags.js +49 -0
  118. package/src/server/rooms/live-page-history.js +41 -0
  119. package/src/server/rooms/live-provenance.js +142 -0
  120. package/src/server/rooms/page-admission.js +79 -0
  121. package/src/server/rooms/page-conversion.js +62 -0
  122. package/src/server/rooms/page-rebase.js +108 -0
  123. package/src/server/rooms/page-state.js +91 -0
  124. package/src/server/rooms/page-write-admission.js +63 -0
  125. package/src/server/rooms/page-write-guard.js +23 -0
  126. package/src/server/rooms/recent.js +18 -0
  127. package/src/server/rooms/spec-wire.js +17 -0
  128. package/src/standalone.js +22 -0
  129. package/src/sync/live-sync.js +13 -2
  130. package/src/sync/stream.js +62 -16
  131. package/src/ui/index.js +4 -0
  132. package/src/ui/toast.js +21 -5
  133. package/src/vendor/pierre/pierre.vendor.js +3 -0
  134. package/src/vendor/yjs.vendor.js +7 -0
  135. package/src/page/binding.js +0 -647
  136. package/src/page/replay.js +0 -578
@@ -0,0 +1,547 @@
1
+ import { createPageEditor } from "../page/editor.js";
2
+ import { readFile, installAdoption } from "../page/adopt-boot.js";
3
+ import { classifyFile } from "../page/adopt.js";
4
+ import { renderFromTags } from "../page/save-projection.js";
5
+ import { blocksFromYValue } from "../page/ybind/decode.js";
6
+ import { createYPageBinding } from "../page/ybind/binding.js";
7
+ import { authorIdentity } from "../page/ybind/author.js";
8
+ import { yValueOf } from "../page/ybind/provider.js";
9
+ import { seedPageFromArticle } from "../page/ybind/migrate.js";
10
+ import { serializeLive } from "../live/ops.js";
11
+ import { registerUnsavedState } from "../lib/unsaved-state.js";
12
+ import { hostMeta, hostSupports } from "../core/host-meta.js";
13
+ import { savePageForce, setLastSavedBaselines, holdAllSaves, releaseAllSaves, getLastSavedBytes } from "../core/save.js";
14
+ import { captureForComparisonAndDirty, captureForSaveAndComparison } from "../core/snapshot.js";
15
+ import { storeOf, checkpointWithin, requestCheckpoint, fastLane, pageLaneReady, pageWriterReady } from "./live.js";
16
+ import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
17
+ import { saveStateFor, BLOCKED_TEXT, RETRY_TEXT, downloadPageCopy, downloadSnapshot, copyPageText } from "../page/save-states.js";
18
+ import toast, { toastPersistent, dismissPersistent } from "../ui/toast.js";
19
+ import { createRecoverList, focusBlock } from "../page/recover.js";
20
+ import { createToolHost } from "../page/tools.js";
21
+ import { createAiTool } from "../page/ai-tool.js";
22
+ import { tablesFromDoc } from "../page/tool-data.js";
23
+ import { capabilityAdvertised } from "../page/rich/page-tool.js";
24
+
25
+ const ARTICLE_SEL = "article[data-page]";
26
+ const TAG_SEL = 'script[type="application/json"][merge="page"][clay~="live"]';
27
+ const BLOCKED = { state: "blocked", code: "unsupported-host", message: "This host does not support page editing" };
28
+ const RETRY_BOUND_MS = 45000;
29
+ const GENERIC_ERROR = "Couldn't save";
30
+ const GENERIC_OFFLINE = "Offline, not saved";
31
+ const REPLACED_TEXT = "This page was changed outside the editor. Reload to see it.";
32
+ const TOO_LARGE_TEXT = "That change is too large to share, so it was not saved. Reload the page to keep editing.";
33
+ const OVERWRITTEN_TEXT = "Some changes you made were replaced by a change made outside the editor.";
34
+ const REPLACED_HOLD = "The page was changed outside the editor; reload to see it.";
35
+
36
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
37
+
38
+ let generation = 0;
39
+ let active = null;
40
+
41
+ // Shorter than installAdoption's 10 s hold, so an unelected room still adopts.
42
+ export const WRITER_WAIT_MS = 8_000;
43
+
44
+ function scrollToHashBlock(view, loc = globalThis.location) {
45
+ const raw = loc?.hash?.slice(1);
46
+ if (!raw) return;
47
+ let id;
48
+ try { id = decodeURIComponent(raw); } catch { return; }
49
+ let found = -1;
50
+ view.state.doc.forEach((node, pos) => { if (found < 0 && node.attrs.id === id) found = pos; });
51
+ if (found < 0) return;
52
+ globalThis.requestAnimationFrame?.(() => view.nodeDOM(found)?.scrollIntoView?.({ block: "start" }));
53
+ }
54
+
55
+ function abandon(inst) {
56
+ if (active === inst) active = null;
57
+ }
58
+
59
+ function dispatchState(state, code, message) {
60
+ document.dispatchEvent(new CustomEvent("clay:page-save-state", { detail: { state, code, message } }));
61
+ }
62
+
63
+ function dismissGeneric() {
64
+ dismissPersistent(GENERIC_ERROR);
65
+ dismissPersistent(GENERIC_OFFLINE);
66
+ }
67
+
68
+ function clearRetry(inst) {
69
+ if (inst.retryTimer) {
70
+ clearTimeout(inst.retryTimer);
71
+ inst.retryTimer = null;
72
+ }
73
+ inst.retryShown = false;
74
+ dismissPersistent(RETRY_TEXT);
75
+ }
76
+
77
+ function armRetry(inst, code, message) {
78
+ dismissGeneric();
79
+ dispatchState("retrying", code, message);
80
+ // One clock for a run of temporary failures: a later retry of the same save
81
+ // does not push the bound out again, and once it has passed the toast stands.
82
+ if (inst.retryTimer || inst.retryShown) return;
83
+ inst.retryTimer = setTimeout(() => {
84
+ inst.retryTimer = null;
85
+ inst.retryShown = true;
86
+ toastPersistent(RETRY_TEXT, "warning");
87
+ }, RETRY_BOUND_MS);
88
+ }
89
+
90
+ function showBlocked(inst, code, message) {
91
+ clearRetry(inst);
92
+ dismissGeneric();
93
+ dispatchState("blocked", code, message);
94
+ toastPersistent(BLOCKED_TEXT, "error", {
95
+ detail: message,
96
+ actions: [
97
+ { label: "Download a copy", onClick: downloadPageCopy },
98
+ { label: "Copy text", onClick: copyPageText },
99
+ ],
100
+ });
101
+ }
102
+
103
+ function showSaved(inst) {
104
+ clearRetry(inst);
105
+ dismissPersistent(BLOCKED_TEXT);
106
+ dispatchState("saved", null, "");
107
+ }
108
+
109
+ // An incoming document with no page tag whose article is not the one this tab
110
+ // last saved: something outside the editor replaced the page, and this tab's
111
+ // state no longer belongs to the file.
112
+ // On a host that keeps no page data every file comes untagged and is merged
113
+ // instead (adopt-boot.js); only a file with no lone article replaced the page.
114
+ function pageReplaced(html, savedRev, mergeTagless = false) {
115
+ if (typeof html !== "string" || !html) return false;
116
+ let doc;
117
+ try { doc = new DOMParser().parseFromString(html, "text/html"); } catch { return false; }
118
+ const articles = doc.querySelectorAll(ARTICLE_SEL);
119
+ if (mergeTagless) return articles.length !== 1 && !doc.querySelector(TAG_SEL);
120
+ if (articles.length !== 1 || doc.querySelector(TAG_SEL)) return false;
121
+ return articles[0].getAttribute("data-rev") !== savedRev;
122
+ }
123
+
124
+ function savedRevOf(inst) {
125
+ if (!inst.store) return inst.savedRev;
126
+ try { return renderFromTags([JSON.stringify(inst.store.value)]).rev; } catch { return inst.savedRev; }
127
+ }
128
+
129
+ // Nothing of this tab's may reach the file again: the editor is read-only, the
130
+ // binding is detached from the store (no frame, no flush), and every save is
131
+ // held, so the old article cannot be written back over the replacement.
132
+ function stopEditing(inst) {
133
+ const view = inst.view;
134
+ if (view) {
135
+ view.setProps({ editable: () => false });
136
+ view.setProps({ dispatchTransaction: (tr) => view.updateState(view.state.apply(tr)) });
137
+ }
138
+ try { inst.binding?.dispose(); } catch (error) { console.error("clayjs: page plugin binding failed to dispose", error); }
139
+ if (inst.savesHeld) return;
140
+ inst.savesHeld = true;
141
+ holdAllSaves(REPLACED_HOLD);
142
+ }
143
+
144
+ // The tab's own page, when the store no longer holds it (another page replaced it, or the
145
+ // room refused it): the saved document with the article rendered from the tab's document.
146
+ function downloadTabCopy(inst) {
147
+ if (!inst.tabValue) return downloadPageCopy();
148
+ const { forSave } = captureForSaveAndComparison();
149
+ const doc = new DOMParser().parseFromString(forSave, "text/html");
150
+ const articles = doc.querySelectorAll(ARTICLE_SEL);
151
+ if (articles.length !== 1) return downloadPageCopy();
152
+ const rendered = renderFromTags([JSON.stringify(inst.tabValue)]);
153
+ articles[0].innerHTML = rendered.html;
154
+ if (rendered.rev == null) articles[0].removeAttribute("data-rev");
155
+ else articles[0].setAttribute("data-rev", rendered.rev);
156
+ return downloadSnapshot(`<!DOCTYPE html>\n${doc.documentElement.outerHTML}`);
157
+ }
158
+
159
+ function stopWithNotice(inst, text) {
160
+ if (inst.replaced) return;
161
+ inst.replaced = true;
162
+ stopEditing(inst);
163
+ toastPersistent(text, "warning", {
164
+ actions: [
165
+ { label: "Reload", onClick: () => window.location.reload() },
166
+ { label: "Download your copy", onClick: () => downloadTabCopy(inst) },
167
+ ],
168
+ });
169
+ }
170
+
171
+ const showReplaced = (inst) => stopWithNotice(inst, REPLACED_TEXT);
172
+ const showTooLarge = (inst) => stopWithNotice(inst, TOO_LARGE_TEXT);
173
+
174
+ function applySaveState(inst, state, code, message) {
175
+ if (state === "saved") showSaved(inst);
176
+ else if (state === "retrying") armRetry(inst, code, message);
177
+ else if (state === "blocked") showBlocked(inst, code, message);
178
+ }
179
+
180
+ function listen(inst, type, handler) {
181
+ document.addEventListener(type, handler);
182
+ inst.listeners.push([type, handler]);
183
+ }
184
+
185
+ function listenForSaves(inst) {
186
+ listen(inst, "clay:save-error", (event) => {
187
+ const code = event.detail?.code ?? null;
188
+ applySaveState(inst, saveStateFor({ code }), code, event.detail?.msg ?? "");
189
+ });
190
+ listen(inst, "clay:save-offline", (event) => {
191
+ const code = event.detail?.code ?? null;
192
+ applySaveState(inst, saveStateFor({ code, offline: true }), code, event.detail?.msg ?? "");
193
+ });
194
+ listen(inst, "clay:save-conflict", (event) => {
195
+ const code = event.detail?.code ?? null;
196
+ applySaveState(inst, saveStateFor({ code }), code, event.detail?.msg ?? "");
197
+ });
198
+ listen(inst, "clay:save-saved", (event) => {
199
+ applySaveState(inst, saveStateFor({ ok: true }), null, event.detail?.msg ?? "");
200
+ inst.savedRev = savedRevOf(inst);
201
+ inst.adoption?.saved(getLastSavedBytes());
202
+ });
203
+ listen(inst, "clay:live-notice", (event) => {
204
+ if (event.detail?.name !== "page") return;
205
+ if (event.detail?.code === "op-too-large") return;
206
+ if (event.detail?.code === "unknown-store") {
207
+ showReplaced(inst);
208
+ return;
209
+ }
210
+ const code = event.detail?.code ?? null;
211
+ applySaveState(inst, saveStateFor({ code }), code, event.detail?.message ?? "");
212
+ });
213
+ }
214
+
215
+ function restoreArticle(inst) {
216
+ const article = inst.article;
217
+ if (!article) return;
218
+ let html = inst.bootHtml;
219
+ let rev = inst.bootRev;
220
+ if (inst.store) {
221
+ try {
222
+ const rendered = renderFromTags([JSON.stringify(inst.store.value)]);
223
+ html = rendered.html;
224
+ rev = rendered.rev;
225
+ } catch (error) {
226
+ console.error("clayjs: page plugin could not render the saved article", error);
227
+ }
228
+ }
229
+ article.innerHTML = html;
230
+ if (rev == null) article.removeAttribute("data-rev");
231
+ else article.setAttribute("data-rev", rev);
232
+ }
233
+
234
+ function dispose() {
235
+ generation += 1;
236
+ const inst = active;
237
+ active = null;
238
+ if (!inst) return;
239
+ if (inst.retryTimer) {
240
+ clearTimeout(inst.retryTimer);
241
+ inst.retryTimer = null;
242
+ }
243
+ for (const [type, handler] of inst.listeners) document.removeEventListener(type, handler);
244
+ inst.listeners = [];
245
+ dismissPersistent(RETRY_TEXT);
246
+ dismissPersistent(BLOCKED_TEXT);
247
+ dismissPersistent(REPLACED_TEXT);
248
+ if (inst.savesHeld) {
249
+ inst.savesHeld = false;
250
+ releaseAllSaves({ replay: false, reason: REPLACED_HOLD });
251
+ }
252
+ try { inst.adoption?.dispose(); } catch (error) { console.error("clayjs: page plugin adoption failed to dispose", error); }
253
+ try { inst.recover?.destroy(); } catch (error) { console.error("clayjs: page plugin recover list failed to dispose", error); }
254
+ try { inst.view?.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
255
+ inst.aiTool?.dispose();
256
+ inst.toolHost?.dispose();
257
+ if (inst.article) {
258
+ const shell = inst.article.querySelector(".page-shell");
259
+ if (shell) shell.remove();
260
+ }
261
+ try { inst.binding?.dispose(); } catch (error) { console.error("clayjs: page plugin binding failed to dispose", error); }
262
+ try { inst.binding?.ydoc?.destroy(); } catch (error) { console.error("clayjs: page plugin document failed to destroy", error); }
263
+ restoreArticle(inst);
264
+ }
265
+
266
+ async function run(inst, gen, options = {}) {
267
+ const articles = document.querySelectorAll(ARTICLE_SEL);
268
+ let tags = document.querySelectorAll(TAG_SEL);
269
+ if (!articles.length && !tags.length) return abandon(inst);
270
+ // A page saved without its merge state (HTML Clay, an outside edit, a download)
271
+ // is booted from its article alone: the seed is deterministic, so every tab
272
+ // rebuilding the same article holds the same state and their edits merge.
273
+ const seeded = articles.length === 1 && tags.length === 0;
274
+ if (seeded) {
275
+ const saved = readFile(document);
276
+ if (!saved) return abandon(inst);
277
+ const tag = document.createElement("script");
278
+ tag.setAttribute("type", "application/json");
279
+ tag.setAttribute("merge", "page");
280
+ tag.setAttribute("clay", "live");
281
+ tag.textContent = serializeLive(seedPageFromArticle(saved.article));
282
+ articles[0].before(tag);
283
+ tags = document.querySelectorAll(TAG_SEL);
284
+ }
285
+ if (articles.length !== 1 || tags.length !== 1) {
286
+ console.warn(`clayjs: page plugin needs exactly one article[data-page] and one merge="page" live tag; found ${articles.length} article(s) and ${tags.length} tag(s), leaving the page as saved HTML`);
287
+ return abandon(inst);
288
+ }
289
+
290
+ const article = articles[0];
291
+ const file = readFile(document);
292
+ if (!file) return abandon(inst);
293
+ let value = null;
294
+ try { value = HyperMorph.parseJsonRelaxed(tags[0].textContent); } catch (error) { value = null; }
295
+ if (!isPlainObject(value) || value.v !== 1 || !isPlainObject(value.y) || Object.hasOwn(value, "blocks") || Object.hasOwn(value, "deleted")) {
296
+ console.warn("clayjs: page plugin: the page tag is not a strict Yjs page, leaving the page as saved HTML");
297
+ return abandon(inst);
298
+ }
299
+ try {
300
+ blocksFromYValue(value);
301
+ } catch (error) {
302
+ console.warn("clayjs: page plugin: the page tag is not a valid Yjs page, leaving the page as saved HTML", error);
303
+ return abandon(inst);
304
+ }
305
+ const source = { article: file.article, tags: [value] };
306
+
307
+ let meta = null;
308
+ try { meta = await hostMeta(); } catch (error) { meta = null; }
309
+ if (gen !== generation) return;
310
+ if (!meta || !Array.isArray(meta.extensions) || !meta.extensions.includes("page")) {
311
+ showBlocked(inst, BLOCKED.code, BLOCKED.message);
312
+ return;
313
+ }
314
+
315
+ // The room was opened from a document that had no page tag, so it holds no page
316
+ // store and would answer this tab's frames with unknown-store. One conditional
317
+ // save of the document with the tag is the page's initial write; a tab that
318
+ // loses that race reloads, which composes the state the winner initialized.
319
+ inst.mergeTagless = !meta.extensions.includes("live-data");
320
+ let initialSave = false;
321
+ if (seeded && (await hostSupports("live-data"))) {
322
+ if (gen !== generation) return;
323
+ const result = await savePageForce();
324
+ if (gen !== generation) return;
325
+ if (!result?.ok) {
326
+ if (result?.status === 412) {
327
+ window.location.reload();
328
+ return;
329
+ }
330
+ showBlocked(inst, result?.code ?? null, result?.msg ?? "");
331
+ return;
332
+ }
333
+ initialSave = true;
334
+ }
335
+
336
+ let handle;
337
+ try {
338
+ handle = window.clay.live("page");
339
+ } catch (error) {
340
+ console.warn("clayjs: page plugin could not open the page store", error);
341
+ return abandon(inst);
342
+ }
343
+ await handle.ready;
344
+ if (gen !== generation) return;
345
+ const store = storeOf("page");
346
+ if (!store) return abandon(inst);
347
+ inst.store = store;
348
+
349
+ let flushFailed = false;
350
+ let registered = null;
351
+ const trackFlush = async (call) => {
352
+ const result = await call();
353
+ flushFailed = result?.ok === false;
354
+ registered?.changed();
355
+ return result;
356
+ };
357
+ const identity = authorIdentity();
358
+ const binding = createYPageBinding({
359
+ store,
360
+ onStopped: (reason) => {
361
+ inst.tabValue = yValueOf(binding.ydoc);
362
+ if (reason === "lineage") {
363
+ fastLane()?.abandon?.("page");
364
+ showReplaced(inst);
365
+ } else {
366
+ showTooLarge(inst);
367
+ }
368
+ },
369
+ authorToken: identity.token,
370
+ ownedTokens: identity.owned,
371
+ checkpointWithin,
372
+ requestCheckpoint,
373
+ registerUnsavedState: (def) => {
374
+ registered = registerUnsavedState({
375
+ id: def.id,
376
+ isPending: () => flushFailed || def.isPending(),
377
+ flush: () => trackFlush(() => def.flush()),
378
+ });
379
+ return registered;
380
+ },
381
+ });
382
+ inst.binding = binding;
383
+ inst.flush = (options) => trackFlush(() => binding.flush(options));
384
+ listenForSaves(inst);
385
+
386
+ inst.source = source;
387
+ inst.article = article;
388
+ inst.bootHtml = source.article.innerHTML;
389
+ inst.bootRev = source.article.getAttribute("data-rev");
390
+ inst.savedRev = inst.bootRev;
391
+ listen(inst, "clay:sync-applied", (event) => {
392
+ if (inst.replaced) return;
393
+ if (!pageReplaced(event.detail?.html, inst.savedRev, inst.mergeTagless)) return;
394
+ showReplaced(inst);
395
+ });
396
+
397
+ // A tool written into the article by hand or by an agent arrives without the
398
+ // store its state lives in, and without one the tool could not keep its state:
399
+ // the page gets the empty store an editor-made page starts with.
400
+ const TOOLS_SEL = 'script[type="application/json"][merge="page.tools"][clay~="live"]';
401
+ if (!document.querySelector(TOOLS_SEL) && article.querySelector('section[data-type="tool"]')) {
402
+ const tools = document.createElement("script");
403
+ tools.setAttribute("type", "application/json");
404
+ tools.setAttribute("merge", "page.tools");
405
+ tools.setAttribute("clay", "live");
406
+ tools.textContent = "{}";
407
+ document.head.appendChild(tools);
408
+ }
409
+ let toolsStore = null;
410
+ if (document.querySelector(TOOLS_SEL)) {
411
+ try {
412
+ toolsStore = window.clay.live("page.tools");
413
+ await toolsStore.ready;
414
+ } catch (error) {
415
+ console.warn("clayjs: page plugin could not open the tool store", error);
416
+ }
417
+ }
418
+ if (gen !== generation) return;
419
+ let editorView = null;
420
+ const toolHost = createToolHost({
421
+ store: toolsStore, mode: "edit",
422
+ tables: () => tablesFromDoc(editorView?.state.doc ?? binding.doc()).map(({ id, cols, rows }) => ({ id, cols, rows })),
423
+ });
424
+ inst.toolHost = toolHost;
425
+ const transport = window.clay?.wire?.pageTool ?? options.transport ?? null;
426
+ if (capabilityAdvertised(meta)) {
427
+ inst.aiTool = createAiTool({ getView: () => inst.view, store: toolsStore, transport, acceptState: options.acceptState });
428
+ }
429
+ article.innerHTML = "";
430
+ const view = createPageEditor(article, {
431
+ doc: binding.doc(),
432
+ dispatch: binding.dispatch,
433
+ history: false,
434
+ undo: binding.undo,
435
+ redo: binding.redo,
436
+ board: binding.board,
437
+ editable: false,
438
+ toolHost,
439
+ onAiTool: inst.aiTool ? (_view, remove) => inst.aiTool.open({ remove }) : null,
440
+ reviseTool: inst.aiTool ? (toolId, rev) => inst.aiTool.open({ target: { toolId, rev } }) : null,
441
+ });
442
+ editorView = view;
443
+ inst.view = view;
444
+ binding.attach(view);
445
+ scrollToHashBlock(view);
446
+ inst.recover = createRecoverList({
447
+ binding,
448
+ mount: view.dom.closest(".page-shell") ?? article,
449
+ ownTokens: [identity.token, ...identity.owned],
450
+ onRestored: (id) => focusBlock(view, id),
451
+ onStale: () => toast("That change was updated. The list now shows the latest version.", "warning"),
452
+ });
453
+
454
+ // A hand-edited article is adopted by the room's elected writer only. With a
455
+ // lane but no writer yet, this tab claims the room (a redundant set of an
456
+ // existing Yjs update: a valid nonempty frame that changes nothing) and then
457
+ // waits for the election, so exactly one tab adopts.
458
+ const needsAdoption = classifyFile(source.article, source.tags).kind === "adopt";
459
+ const awaitWriter = async () => {
460
+ const lane = await pageLaneReady();
461
+ if (!lane) return;
462
+ if (gen !== generation) return;
463
+ if (lane.status.writer === null) {
464
+ const y = store.value.y;
465
+ const keys = Object.keys(y);
466
+ const key = keys.length ? keys[0] : `claim-${binding.ydoc.clientID.toString(36)}`;
467
+ const value = keys.length ? y[keys[0]] : yValueOf(binding.ydoc).y.seed;
468
+ store.handle.set(["y", key], value);
469
+ }
470
+ // A lane that never reports a writer must not cost the hand edit: give up
471
+ // before adoption's own bound, and adopt as the file-mode tab would.
472
+ let timer;
473
+ await Promise.race([pageWriterReady(), new Promise((resolve) => { timer = setTimeout(resolve, WRITER_WAIT_MS); })]);
474
+ clearTimeout(timer);
475
+ };
476
+ const ready = needsAdoption ? awaitWriter() : Promise.resolve();
477
+
478
+ const adoption = installAdoption({
479
+ view,
480
+ source,
481
+ ready,
482
+ isAdopter: () => {
483
+ const lane = fastLane();
484
+ return !lane || lane.status.writer === null ? true : lane.isWriter();
485
+ },
486
+ requestCheckpoint: () => checkpointWithin(0),
487
+ snapshot: () => captureForSaveAndComparison().forSave,
488
+ mergeTagless: inst.mergeTagless,
489
+ writerReady: awaitWriter,
490
+ adopted: (rev) => fastLane()?.adopted?.("page", rev, store.mark),
491
+ notice: (counts) => {
492
+ if (!counts.copy) return;
493
+ toastPersistent(OVERWRITTEN_TEXT, "warning", {
494
+ actions: [{ label: "Download your copy", onClick: () => downloadSnapshot(counts.copy) }],
495
+ });
496
+ },
497
+ });
498
+ inst.adoption = adoption;
499
+
500
+ await adoption.idle();
501
+ if (gen !== generation) return;
502
+ // A replacement that landed while the page was still booting keeps the editor
503
+ // read-only: the notice is already up, and this tab must not edit the old article.
504
+ if (inst.replaced) {
505
+ stopEditing(inst);
506
+ return;
507
+ }
508
+ view.setProps({ editable: () => true });
509
+
510
+ // That initial write advanced the saved baselines from a capture taken before
511
+ // the editor mounted, which is what the load-time settle would otherwise absorb
512
+ // boot churn into. Install them once more from the booted page, so a tab that
513
+ // was opened and left alone does not read as unsaved.
514
+ if (initialSave) {
515
+ const { forComparison, forDirty } = captureForComparisonAndDirty();
516
+ setLastSavedBaselines(forComparison, forDirty);
517
+ }
518
+ }
519
+
520
+ function install(options = {}) {
521
+ dispose();
522
+ const gen = ++generation;
523
+ const inst = { gen, article: null, view: null, binding: null, adoption: null, source: null, store: null, toolHost: null, aiTool: null, bootHtml: "", bootRev: null, savedRev: null, replaced: false, savesHeld: false, flush: null, listeners: [], retryTimer: null, retryShown: false };
524
+ active = inst;
525
+ return run(inst, gen, options).catch((error) => {
526
+ console.error("clayjs: page plugin failed to install", error);
527
+ if (active === inst) dispose();
528
+ });
529
+ }
530
+
531
+ export const page = {
532
+ ready: null,
533
+ install,
534
+ flush(options = {}) {
535
+ const inst = active;
536
+ if (!inst || !inst.flush) return Promise.resolve({ ok: false, code: "disposed" });
537
+ return inst.flush(options);
538
+ },
539
+ dispose,
540
+ get view() {
541
+ return active?.view ?? null;
542
+ },
543
+ };
544
+
545
+ page.ready = install();
546
+
547
+ export default page;
@@ -6,6 +6,7 @@
6
6
  import { undo } from '../vendor/hyper-undo.vendor.js'
7
7
  import { isEditMode } from '../core/is-edit-mode.js'
8
8
  import { isTabLocalAttr } from '../lib/root-attrs.js'
9
+ import { PERSIST } from '../lib/attr-aliases.js'
9
10
 
10
11
  // Tab-local attributes (ephemeral state, route-active, root library state) are
11
12
  // never undone. The shared mutation hub already drops them for the main
@@ -21,18 +22,74 @@ const withTabLocal = (opts = {}) => {
21
22
  const createScope = undo.create.bind(undo)
22
23
  undo.create = (opts) => createScope(withTabLocal(opts))
23
24
 
25
+ // A commit inside clay.effect drains the paused hub's records, so the effect's
26
+ // own writes reach autosave and re-trigger it. Warn once; the commit still runs.
27
+ const commit = undo.commit.bind(undo)
28
+ let warnedEffectCommit = false
29
+ undo.commit = (label, fn) => {
30
+ if (!warnedEffectCommit && window.clay?.effect?.running) {
31
+ warnedEffectCommit = true
32
+ console.warn(`clay.effect: undo.commit("${label}") was called inside an effect. Run commands from event handlers, not from an effect.`)
33
+ }
34
+ return commit(label, fn)
35
+ }
36
+
37
+ // Undo and redo restore attributes. A control the user has touched shows its
38
+ // live property instead (the WHATWG dirty flag), so copy the attributes back, as
39
+ // a sync merge does. Undo is the user's own request, so the focused field is
40
+ // restored too, with its selection clamped to the new length. Controls persist
41
+ // never writes (password, hidden, file) and regions undo does not record are
42
+ // left alone.
43
+ const CONTROLS = `input${PERSIST}:not([type="password"]):not([type="hidden"]):not([type="file"]), textarea${PERSIST}, select${PERSIST}`
44
+
45
+ function restoreControls() {
46
+ const undoable = (el) => window.clay?.region?.resolveRegionPolicy?.(el)?.undoable !== false
47
+ document.querySelectorAll(CONTROLS).forEach((el) => {
48
+ if (!undoable(el)) return
49
+ if (el.tagName === 'SELECT') {
50
+ for (const opt of el.options) {
51
+ const want = opt.hasAttribute('selected')
52
+ if (opt.selected !== want) opt.selected = want
53
+ }
54
+ return
55
+ }
56
+ const type = (el.getAttribute('type') || 'text').toLowerCase()
57
+ if (el.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) {
58
+ const want = el.hasAttribute('checked')
59
+ if (el.checked !== want) el.checked = want
60
+ return
61
+ }
62
+ const want = el.tagName === 'TEXTAREA'
63
+ ? (el.getAttribute('data-value') ?? el.textContent)
64
+ : (el.getAttribute('value') ?? '')
65
+ if (el.value === want) return
66
+ const focused = el === document.activeElement
67
+ let start, end
68
+ try { start = el.selectionStart; end = el.selectionEnd } catch {}
69
+ el.value = want
70
+ if (focused && start != null) {
71
+ try { el.setSelectionRange(Math.min(start, want.length), Math.min(end, want.length)) } catch {}
72
+ }
73
+ })
74
+ }
75
+
24
76
  function init() {
25
77
  if (!isEditMode) return
26
78
  // Defer until the body exists so the default scope (document.body) is valid.
27
79
  if (typeof document === 'undefined') return
80
+ const start = () => {
81
+ undo.start(withTabLocal({ bindKeys: true }))
82
+ undo.on('undo', restoreControls)
83
+ undo.on('redo', restoreControls)
84
+ }
28
85
  if (document.readyState === 'loading') {
29
- document.addEventListener('DOMContentLoaded', () => undo.start(withTabLocal({ bindKeys: true })))
86
+ document.addEventListener('DOMContentLoaded', start)
30
87
  } else {
31
- undo.start(withTabLocal({ bindKeys: true }))
88
+ start()
32
89
  }
33
90
  }
34
91
 
35
92
  init()
36
93
 
37
- export { undo }
94
+ export { undo, restoreControls }
38
95
  export default undo