@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,240 @@
1
+ // Adopting a hand-edited article (D2: the article wins, no prompt).
2
+ //
3
+ // Three inputs: the blocks the article was rendered from (base: the live tags
4
+ // in the same file), the article as parsed now, and the editor's document
5
+ // (current, which may have moved on since the file was written). Only what the
6
+ // article changed relative to base is applied, as ONE local ProseMirror
7
+ // transaction: the binding turns it into ops like any edit, identityPlugin
8
+ // rewrites the fewest pos keys, structurePlugin repairs parent and indent, and
9
+ // Cmd+Z takes the whole adoption back.
10
+ import { model as pmModel, history } from "../vendor/prosemirror.vendor.js";
11
+ import { blockMap, nodeFromBlock, repairSiblings } from "./model.js";
12
+ import { project, canon, revOf, parseModel, renderModel, fragmentCleaner } from "./projection.js";
13
+ import { blocksFromPageValues } from "./ybind/decode.js";
14
+
15
+ export const ADOPT = "page-adopt";
16
+ const same = (a, b) => canon(a) === canon(b);
17
+ const body = ({ id, parent, ...rest }) => ({ ...rest, parent: parent ?? null });
18
+
19
+ // What the article changed relative to base, or null when nothing did.
20
+ // edited: id -> parsed item (content or attrs differ from base)
21
+ // created: id -> parsed item (no such block in base)
22
+ // deleted: ids base had and the article dropped
23
+ // order: the article's order of ids, or null when it kept base's order
24
+ // baseItems is what the base looks like read back from its own rendered article
25
+ // (classifyFile computes it), so markup the render and the parse disagree on is
26
+ // never mistaken for a hand edit.
27
+ export function planAdoption(baseBlocks, parsed, baseItems = project(baseBlocks)) {
28
+ const Q = baseItems;
29
+ const q = new Map(Q.map((item) => [item.id, item]));
30
+ const p = new Map(parsed.map((item) => [item.id, item]));
31
+ const edited = new Map(), created = new Map();
32
+ for (const item of parsed) {
33
+ const was = q.get(item.id);
34
+ if (!was) created.set(item.id, item);
35
+ else if (!same(body(was), body(item))) edited.set(item.id, item);
36
+ }
37
+ const deleted = Q.filter((item) => !p.has(item.id)).map((item) => item.id);
38
+ const kept = parsed.filter((item) => q.has(item.id)).map((item) => item.id);
39
+ const baseOrder = Q.filter((item) => p.has(item.id)).map((item) => item.id);
40
+ const reordered = kept.some((id, i) => id !== baseOrder[i]);
41
+ if (!edited.size && !created.size && !deleted.length && !reordered) return null;
42
+ return { edited, created, deleted, order: parsed.map((item) => item.id), reordered, base: q };
43
+ }
44
+
45
+ // ---- model item -> block JSON, keeping what the projection does not carry ----
46
+
47
+ function keyed(list, current, prefix, extra = (x) => x) {
48
+ const items = list.map((x) => ({ id: x.id, pos: current?.[x.id]?.pos ?? null }));
49
+ const fix = repairSiblings(items, prefix);
50
+ const out = {};
51
+ list.forEach((x, i) => { out[x.id] = extra({ ...x, pos: fix[i] ? fix[i].pos : items[i].pos }); });
52
+ return out;
53
+ }
54
+
55
+ export function blockFromItem(item, cur) {
56
+ const { id, t, ...fields } = item;
57
+ const out = { t, pos: cur?.pos ?? null };
58
+ Object.assign(out, fields);
59
+ if (t === "table" && !fields.rows.length) fields.rows = [{ id: `r-${id}`, cells: {} }];
60
+ if (t === "table") {
61
+ out.cols = keyed(fields.cols, cur?.cols, "c", ({ id: _i, ...c }) => c);
62
+ out.rows = keyed(fields.rows, cur?.rows, "r", ({ id: _i, cells, pos }) => ({ pos, cells }));
63
+ }
64
+ if (t === "board") {
65
+ out.columns = keyed(fields.columns, cur?.columns, "c", ({ id: _i, ...c }) => c);
66
+ const cards = {};
67
+ const byCol = new Map();
68
+ for (const k of fields.cards) (byCol.get(k.col) ?? byCol.set(k.col, []).get(k.col)).push(k);
69
+ for (const list of byCol.values()) Object.assign(cards, keyed(list, cur?.cards, "k", ({ id: _i, ...k }) => k));
70
+ out.cards = cards;
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // ---- the transaction ---------------------------------------------------------
76
+
77
+ function find(doc, id) {
78
+ let found = null;
79
+ doc.forEach((node, pos) => { if (!found && node.attrs.id === id) found = { node, pos }; });
80
+ return found;
81
+ }
82
+
83
+ // Longest run of ids, in target order, whose current positions already increase:
84
+ // those stay; every other existing block is lifted out and put back.
85
+ function staying(target, index) {
86
+ const seq = target.filter((id) => index.has(id));
87
+ const tails = [], tailAt = [], prev = new Array(seq.length).fill(-1);
88
+ seq.forEach((id, i) => {
89
+ const k = index.get(id);
90
+ let lo = 0, hi = tails.length;
91
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (tails[mid] < k) lo = mid + 1; else hi = mid; }
92
+ if (lo > 0) prev[i] = tailAt[lo - 1];
93
+ tails[lo] = k; tailAt[lo] = i;
94
+ });
95
+ const keep = new Set();
96
+ for (let i = tailAt[tails.length - 1] ?? -1; i >= 0; i = prev[i]) keep.add(seq[i]);
97
+ return keep;
98
+ }
99
+
100
+ function replaceInPlace(tr, id, next) {
101
+ const { node, pos } = find(tr.doc, id);
102
+ const attrs = { ...next.attrs, id: node.attrs.id, pos: node.attrs.pos };
103
+ if (node.isTextblock && next.isTextblock && next.type.validContent(node.content)) {
104
+ if (node.type !== next.type || !same(node.attrs, attrs)) tr.setNodeMarkup(pos, next.type, attrs);
105
+ const cur = find(tr.doc, id).node;
106
+ const s = cur.content.findDiffStart(next.content);
107
+ if (s == null) return;
108
+ let { a, b } = cur.content.findDiffEnd(next.content);
109
+ const overlap = s - Math.min(a, b);
110
+ if (overlap > 0) { a += overlap; b += overlap; }
111
+ tr.replace(pos + 1 + s, pos + 1 + a, new pmModel.Slice(next.content.cut(s, b), 0, 0));
112
+ return;
113
+ }
114
+ tr.replaceWith(pos, pos + node.nodeSize, next.type.create(attrs, next.content, next.marks));
115
+ }
116
+
117
+ // The adoption as one transaction on state, or null when there is nothing to do.
118
+ export function adoptionTransaction(state, plan) {
119
+ if (!plan) return null;
120
+ const schema = state.schema;
121
+ const doc = state.doc;
122
+ const current = blockMap(doc);
123
+ const tr = state.tr;
124
+ const projected = new Map(project(current).map((item) => [item.id, item]));
125
+ const displaced = {};
126
+ for (const id of [...plan.edited.keys(), ...plan.deleted]) {
127
+ const was = plan.base.get(id), now = projected.get(id);
128
+ if (was && now && !same(body(was), body(now))) displaced[id] = current[id];
129
+ }
130
+ const nodeOf = (id, item) => nodeFromBlock(schema, id, blockFromItem(item, current[id]));
131
+
132
+ // Adopting into a page that had no blocks: the editor's empty placeholder line
133
+ // goes, after the article's blocks are in (an empty doc would get a new one).
134
+ const placeholders = plan.base.size ? [] : Object.keys(current).filter((id) => current[id].t === "paragraph" && !current[id].c && !plan.order.includes(id));
135
+ for (const id of plan.deleted) {
136
+ const f = find(tr.doc, id);
137
+ if (f) tr.delete(f.pos, f.pos + f.node.nodeSize);
138
+ }
139
+
140
+ // Target order: the article's, with blocks the article never knew (written by
141
+ // someone else since the file was saved) kept after their current predecessor.
142
+ const curOrder = [];
143
+ tr.doc.forEach((n) => curOrder.push(n.attrs.id));
144
+ const known = new Set(plan.order);
145
+ let target = plan.order.filter((id) => !plan.deleted.includes(id));
146
+ if (!plan.reordered) {
147
+ // Keep the current order; place each new block after the block before it in the article.
148
+ target = curOrder.slice();
149
+ plan.order.forEach((id, i) => {
150
+ if (!plan.created.has(id) || target.includes(id)) return;
151
+ let j = i - 1;
152
+ while (j >= 0 && !target.includes(plan.order[j])) j--;
153
+ target.splice(j >= 0 ? target.indexOf(plan.order[j]) + 1 : 0, 0, id);
154
+ });
155
+ } else {
156
+ curOrder.forEach((id, i) => {
157
+ if (known.has(id)) return;
158
+ const before = curOrder.slice(0, i).reverse().find((x) => target.includes(x));
159
+ target.splice(before ? target.indexOf(before) + 1 : 0, 0, id);
160
+ });
161
+ }
162
+
163
+ const index = new Map(curOrder.map((id, i) => [id, i]));
164
+ // A block the article shows but someone else deleted since the save stays
165
+ // deleted unless the article changed it.
166
+ target = target.filter((id) => index.has(id) || plan.edited.has(id) || plan.created.has(id));
167
+ const keep = staying(target, index);
168
+ const lifted = new Map();
169
+ for (const id of curOrder.slice().reverse()) {
170
+ if (keep.has(id) || !target.includes(id)) continue;
171
+ const f = find(tr.doc, id);
172
+ lifted.set(id, f.node);
173
+ tr.delete(f.pos, f.pos + f.node.nodeSize);
174
+ }
175
+ let cursor = 0;
176
+ for (const id of target) {
177
+ if (keep.has(id)) {
178
+ const f = find(tr.doc, id);
179
+ cursor = f.pos + f.node.nodeSize;
180
+ continue;
181
+ }
182
+ const item = plan.edited.get(id) || plan.created.get(id);
183
+ const node = item ? nodeOf(id, item) : lifted.get(id);
184
+ tr.insert(cursor, node);
185
+ cursor += node.nodeSize;
186
+ }
187
+ for (const [id, item] of [...plan.edited, ...plan.created]) if (keep.has(id)) replaceInPlace(tr, id, nodeOf(id, item));
188
+ for (const id of placeholders) {
189
+ const f = find(tr.doc, id);
190
+ if (f && tr.doc.childCount > 1) tr.delete(f.pos, f.pos + f.node.nodeSize);
191
+ }
192
+ if (!tr.docChanged) return null;
193
+ return history.closeHistory(tr).setMeta(ADOPT, { displaced });
194
+ }
195
+
196
+ // Blocks as their article shows them, read back through render and parse, so
197
+ // both sides of a comparison carry the same markup losses.
198
+ export function readBack(blocks, doc, seed = "") {
199
+ const model = project(blocks);
200
+ const known = new Map(model.map((i) => [i.id, i]));
201
+ const fallbackOf = (id) => (typeof blocks[id]?.artifact?.fallbackHtml === "string" ? blocks[id].artifact.fallbackHtml : "");
202
+ const rendered = doc.createElement("article");
203
+ rendered.innerHTML = renderModel(model, { fallbackOf, clean: fragmentCleaner(doc) });
204
+ return parseModel(rendered, { seed, known });
205
+ }
206
+
207
+ // ---- which file states are adopted ------------------------------------------
208
+ //
209
+ // data-rev ties one save's article to the tags written in the same save:
210
+ // R the article's data-rev A revOf(parse(article))
211
+ // T revOf(project(the file's tags))
212
+ // A == R: the article is as rendered; nothing to adopt.
213
+ // A != R, T == R (or no R): the article was edited and the tags were not, so the
214
+ // tags are the base the edit started from: adopt.
215
+ // A != R, T != R: both forms moved (or this is a peer's pre-save snapshot,
216
+ // whose article is its load-time copy): no base to diff from; not adopted.
217
+ // With no R (a page written as plain HTML), the article is adopted only when the
218
+ // tags hold nothing it does not show: a stale copy of a hand-authored article
219
+ // must not revert everything typed since it was first adopted.
220
+ //
221
+ // Fresh ids depend only on R, so frames edited from the same save (before a
222
+ // checkpoint stamps the new blocks' ids) give the same new block the same id.
223
+ export function classifyFile(article, tagValues) {
224
+ const R = article.getAttribute("data-rev");
225
+ const base = blocksFromPageValues(tagValues);
226
+ const model = project(base);
227
+ const known = new Map(model.map((i) => [i.id, i]));
228
+ const seed = R ?? "";
229
+ const parsed = parseModel(article, { seed, known });
230
+ const A = revOf(parsed);
231
+ if (R && A === R) return { kind: "untouched" };
232
+ const T = revOf(model);
233
+ if (R && T !== R) return { kind: "unbased", A, R, T };
234
+ if (!R) {
235
+ const shown = new Set(parsed.map((i) => i.id));
236
+ if (!model.every((i) => shown.has(i.id))) return { kind: "unbased", A, R, T };
237
+ }
238
+ const baseItems = readBack(base, article.ownerDocument, seed);
239
+ return { kind: "adopt", base, parsed, baseItems, A };
240
+ }
@@ -0,0 +1,188 @@
1
+ import { state } from "../vendor/prosemirror.vendor.js";
2
+ import { schema } from "./schema.js";
3
+ import { asStep } from "./blocksel.js";
4
+ import { keyBetween, newId } from "./model.js";
5
+ import { createToolClient } from "./rich/page-tool.js";
6
+ import { createToolHost } from "./tools.js";
7
+ import { toolContext } from "./tool-data.js";
8
+
9
+ const { Selection } = state;
10
+
11
+ function findBlock(doc, id) {
12
+ let found = null;
13
+ doc.forEach((node, at, index) => { if (node.attrs.id === id) found = { node, at, index }; });
14
+ return found;
15
+ }
16
+
17
+ function insertCandidate(view, candidate, bookmark) {
18
+ const st = view.state;
19
+ const found = findBlock(st.doc, bookmark?.hostId) ?? (() => {
20
+ const index = Math.min(st.selection.$head.index(0), st.doc.childCount - 1);
21
+ let at = 0;
22
+ for (let i = 0; i < index; i++) at += st.doc.child(i).nodeSize;
23
+ return { node: st.doc.child(index), at, index };
24
+ })();
25
+ const { node: current, at, index } = found;
26
+ const next = st.doc.maybeChild(index + 1);
27
+ const replace = current.isTextblock && (current.content.size === 0 || bookmark?.hostId === current.attrs.id && current.textContent === bookmark.slashText);
28
+ const pos = replace ? current.attrs.pos : keyBetween(current.attrs.pos, next?.attrs.pos ?? null);
29
+ const tool = schema.nodes.tool.create({
30
+ id: candidate.id, pos, parent: current.attrs.parent ?? null,
31
+ artifact: candidate.artifact, rev: candidate.rev, state: candidate.state,
32
+ });
33
+ const place = replace ? at : at + current.nodeSize;
34
+ const tr = st.tr;
35
+ if (!replace && bookmark?.hostId === current.attrs.id && bookmark.slashText && bookmark.slashOffset >= 0) {
36
+ const from = at + 1 + bookmark.slashOffset;
37
+ const to = from + bookmark.slashText.length;
38
+ if (to <= at + current.nodeSize - 1 && st.doc.textBetween(from, to) === bookmark.slashText) tr.delete(from, to);
39
+ }
40
+ const mapped = tr.mapping.map(place);
41
+ tr.replaceWith(mapped, replace ? tr.mapping.map(at + current.nodeSize) : mapped, tool);
42
+ tr.setSelection(Selection.near(tr.doc.resolve(Math.min(mapped + tool.nodeSize, tr.doc.content.size))));
43
+ view.dispatch(asStep(tr).scrollIntoView());
44
+ }
45
+
46
+ export function createAiTool({ getView, store, transport, acceptState = () => globalThis.clay?.save?.flush?.(), newId: allocate = newId }) {
47
+ const client = transport ? createToolClient({ transport, newId: () => allocate("request") }) : null;
48
+ let panel = null;
49
+ let generation = 0;
50
+
51
+ const close = () => {
52
+ generation++;
53
+ if (!panel) return;
54
+ client?.discard(panel.pending);
55
+ panel.preview?.dispose();
56
+ panel.previewHost?.dispose();
57
+ panel.element.remove();
58
+ panel = null;
59
+ };
60
+
61
+ const open = ({ target = null, remove = null } = {}) => {
62
+ close();
63
+ const view = getView();
64
+ if (!view) return;
65
+ const selection = view.state.selection;
66
+ const host = view.state.doc.child(Math.min(selection.$head.index(0), view.state.doc.childCount - 1));
67
+ const bookmark = {
68
+ hostId: host?.attrs.id,
69
+ slashText: remove ? view.state.doc.textBetween(remove.from, remove.to) : null,
70
+ slashOffset: remove && selection.$head.depth ? remove.from - selection.$head.before(1) - 1 : null,
71
+ };
72
+ const doc = view.dom.ownerDocument;
73
+ const element = doc.createElement("section");
74
+ element.className = "page-ai-panel";
75
+ element.contentEditable = "false";
76
+ element.setAttribute("role", "dialog");
77
+ element.setAttribute("aria-label", target ? "Revise tool" : "Make a tool with AI");
78
+ const input = doc.createElement("textarea");
79
+ input.setAttribute("aria-label", "Describe the tool");
80
+ input.placeholder = target ? "What should change?" : "Describe the tool you want";
81
+ const status = doc.createElement("p");
82
+ status.className = "page-ai-status";
83
+ const previewRoot = doc.createElement("div");
84
+ previewRoot.className = "page-ai-preview";
85
+ const actions = doc.createElement("div");
86
+ actions.className = "page-ai-actions";
87
+ element.append(input, status, previewRoot, actions);
88
+ view.dom.parentElement.append(element);
89
+ panel = { element, input, status, previewRoot, actions, target, bookmark, candidate: null, pending: null, preview: null, previewHost: null, kept: false, busy: false };
90
+
91
+ const button = (label, run) => {
92
+ const el = doc.createElement("button");
93
+ el.type = "button";
94
+ el.textContent = label;
95
+ el.addEventListener("click", run);
96
+ actions.append(el);
97
+ };
98
+ const showPrompt = () => {
99
+ if (!panel) return;
100
+ panel.preview?.dispose();
101
+ panel.previewHost?.dispose();
102
+ panel.preview = null;
103
+ panel.previewHost = null;
104
+ panel.previewRoot.replaceChildren();
105
+ panel.input.hidden = false;
106
+ panel.actions.replaceChildren();
107
+ button("Generate", generate);
108
+ button("Cancel", close);
109
+ panel.input.focus();
110
+ };
111
+ const showCandidate = () => {
112
+ const p = panel;
113
+ p.input.hidden = true;
114
+ p.status.textContent = "";
115
+ p.previewHost = createToolHost({ mode: "view" });
116
+ p.preview = p.previewHost.mount(p.previewRoot, { id: "preview", rev: "preview", artifact: p.candidate.artifact, seed: p.candidate.state });
117
+ p.actions.replaceChildren();
118
+ button("Keep", keep);
119
+ button("Revise", showPrompt);
120
+ button("Discard", close);
121
+ };
122
+ const generate = async () => {
123
+ const p = panel;
124
+ if (!p || p.busy || !p.input.value.trim()) return;
125
+ if (!client) { p.status.textContent = "Tool generation is unavailable"; return; }
126
+ p.busy = true;
127
+ p.status.textContent = "Making your tool…";
128
+ p.actions.replaceChildren();
129
+ button("Cancel", close);
130
+ const turn = ++generation;
131
+ const candidate = client.request(p.input.value.trim(), toolContext(view.state.doc, doc.title), p.target);
132
+ p.pending = candidate;
133
+ try {
134
+ const result = await client.wait(candidate);
135
+ if (panel !== p || turn !== generation) return;
136
+ p.candidate = { artifact: result.artifact, state: result.state, id: null, rev: null };
137
+ showCandidate();
138
+ } catch (error) {
139
+ if (panel !== p || turn !== generation) return;
140
+ p.status.textContent = error.message;
141
+ p.actions.replaceChildren();
142
+ button("Generate", generate);
143
+ button("Cancel", close);
144
+ } finally { p.busy = false; }
145
+ };
146
+ const keep = async () => {
147
+ const p = panel;
148
+ if (!p?.candidate || p.busy || p.kept) return;
149
+ p.busy = true;
150
+ const candidate = p.candidate;
151
+ candidate.id ??= p.target?.toolId ?? allocate("b");
152
+ candidate.rev ??= allocate("rev");
153
+ try {
154
+ if (!store) throw new Error("This document can't save tool state");
155
+ await store.set([candidate.id, candidate.rev], candidate.state);
156
+ const accepted = await acceptState();
157
+ if (accepted?.ok !== true && !["saved", "clean"].includes(accepted?.state)) {
158
+ throw new Error(accepted?.msg ?? accepted?.message ?? "Tool state was not saved");
159
+ }
160
+ if (panel !== p || p.kept) return;
161
+ if (p.target) {
162
+ const found = findBlock(view.state.doc, p.target.toolId);
163
+ if (found?.node.type !== schema.nodes.tool || found.node.attrs.rev !== p.target.rev) {
164
+ p.status.textContent = "This tool changed while you were revising. Keep as a new tool, or discard.";
165
+ p.target = null;
166
+ candidate.id = null;
167
+ candidate.rev = null;
168
+ p.actions.replaceChildren();
169
+ button("Keep as new tool", keep);
170
+ button("Discard", close);
171
+ return;
172
+ }
173
+ view.dispatch(asStep(view.state.tr.setNodeMarkup(found.at, null, {
174
+ ...found.node.attrs, artifact: candidate.artifact, rev: candidate.rev,
175
+ })));
176
+ } else insertCandidate(view, candidate, p.bookmark);
177
+ p.kept = true;
178
+ close();
179
+ } catch (error) {
180
+ if (panel === p) p.status.textContent = error.message;
181
+ } finally { p.busy = false; }
182
+ };
183
+ showPrompt();
184
+ return element;
185
+ };
186
+
187
+ return { open, dispose: close };
188
+ }
@@ -0,0 +1,21 @@
1
+ import { state } from "../vendor/prosemirror.vendor.js";
2
+
3
+ export function announcementPlugin() {
4
+ return new state.Plugin({
5
+ view(view) {
6
+ const live = view.dom.ownerDocument.createElement("div");
7
+ live.className = "page-live";
8
+ live.setAttribute("role", "status");
9
+ live.setAttribute("aria-live", "polite");
10
+ view.dom.parentElement.appendChild(live);
11
+ return { destroy: () => live.remove() };
12
+ },
13
+ });
14
+ }
15
+
16
+ export function announce(view, text) {
17
+ const region = view.dom.parentElement?.querySelector(".page-live");
18
+ if (!region) return;
19
+ region.textContent = "";
20
+ region.textContent = text;
21
+ }