@panphora/clayjs 1.13.0 → 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 (137) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69739 -41784
  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 +95 -94
  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/plugins/visitor-drafts/prompt.js +4 -6
  114. package/src/server/checkpoint/index.js +218 -0
  115. package/src/server/rooms/index.js +59 -0
  116. package/src/server/rooms/live-data-ops.js +369 -0
  117. package/src/server/rooms/live-data-rooms.js +965 -0
  118. package/src/server/rooms/live-data-tags.js +49 -0
  119. package/src/server/rooms/live-page-history.js +41 -0
  120. package/src/server/rooms/live-provenance.js +142 -0
  121. package/src/server/rooms/page-admission.js +79 -0
  122. package/src/server/rooms/page-conversion.js +62 -0
  123. package/src/server/rooms/page-rebase.js +108 -0
  124. package/src/server/rooms/page-state.js +91 -0
  125. package/src/server/rooms/page-write-admission.js +63 -0
  126. package/src/server/rooms/page-write-guard.js +23 -0
  127. package/src/server/rooms/recent.js +18 -0
  128. package/src/server/rooms/spec-wire.js +17 -0
  129. package/src/standalone.js +22 -0
  130. package/src/sync/live-sync.js +13 -2
  131. package/src/sync/stream.js +62 -16
  132. package/src/ui/index.js +4 -0
  133. package/src/ui/toast.js +21 -5
  134. package/src/vendor/pierre/pierre.vendor.js +3 -0
  135. package/src/vendor/yjs.vendor.js +7 -0
  136. package/src/page/binding.js +0 -647
  137. package/src/page/replay.js +0 -578
@@ -0,0 +1,570 @@
1
+ // Image and file uploads. A file being uploaded is a bookmark in plugin state,
2
+ // drawn as a widget at a block boundary and mapped through every transaction.
3
+ // Nothing reaches the record until the host has answered with a URL; then one
4
+ // transaction inserts the block at the bookmark. A cancelled or failed upload
5
+ // leaves no trace in the record.
6
+ import { state, view as pmView } from "../vendor/prosemirror.vendor.js";
7
+ import { schema } from "./schema.js";
8
+ import { outline } from "./blocks.js";
9
+ import { newId } from "./model.js";
10
+ import { asStep } from "./blocksel.js";
11
+ import { announce } from "./announce.js";
12
+ import { safeHref, safeFileHref } from "./address.js";
13
+
14
+ const { Plugin, PluginKey } = state;
15
+ const { Decoration, DecorationSet } = pmView;
16
+ const N = schema.nodes;
17
+
18
+ export const mediaKey = new PluginKey("page-media");
19
+ export const EMBED_MAX = 1.5 * 1024 * 1024;
20
+ const RETRY = new Set(["timeout", "network", "error", "bad-response"]);
21
+ const runtime = new WeakMap();
22
+
23
+ const boundary = (doc, pos) => {
24
+ const $p = doc.resolve(Math.max(0, Math.min(pos, doc.content.size)));
25
+ return $p.depth === 0 ? $p.pos : $p.after(1);
26
+ };
27
+
28
+ export function hostPlace(st, i) {
29
+ const ents = outline(st.doc);
30
+ const e = ents[Math.min(i, ents.length - 1)];
31
+ const empty = e.node.type === N.paragraph && e.node.content.size === 0;
32
+ return { hostId: e.node.attrs.id, at: empty ? e.at : ents[e.subEnd].end, parent: e.node.attrs.parent ?? null, replaceId: empty ? e.node.attrs.id : null };
33
+ }
34
+
35
+ const mediaState = {
36
+ init: () => ({ items: [], placed: {}, picker: null }),
37
+ apply(tr, value) {
38
+ let { items, placed, picker } = value;
39
+ if (tr.docChanged) items = items.map((it) => ({ ...it, pos: boundary(tr.doc, tr.mapping.map(it.pos, -1)) }));
40
+ if (picker && tr.docChanged) {
41
+ picker = { ...picker, place: { ...picker.place, at: boundary(tr.doc, tr.mapping.map(picker.place.at, -1)) } };
42
+ if (picker.remove) {
43
+ const from = tr.mapping.map(picker.remove.from, 1), to = tr.mapping.map(picker.remove.to, -1);
44
+ picker = from <= to && tr.doc.textBetween(from, to) === picker.remove.text
45
+ ? { ...picker, remove: { ...picker.remove, from, to } } : null;
46
+ }
47
+ }
48
+ const m = tr.getMeta(mediaKey);
49
+ if (m?.picker !== undefined) picker = m.picker;
50
+ if (m?.add) items = [...items, ...m.add];
51
+ if (m?.update) items = items.map((it) => (it.id === m.update.id ? { ...it, ...m.update } : it));
52
+ if (m?.placed) {
53
+ const { id, at, size, blockId } = m.placed;
54
+ const done = value.items.find((it) => it.id === id);
55
+ items = items.filter((it) => it.id !== id).map((it) => (done && it.pos === at && it.group === done.group && it.order > done.order ? { ...it, pos: it.pos + size } : it));
56
+ if (done) placed = { ...placed, [done.group]: [...(placed[done.group] || []), blockId] };
57
+ }
58
+ if (m?.remove) items = items.filter((it) => it.id !== m.remove);
59
+ for (const g of Object.keys(placed)) if (!items.some((it) => it.group === g)) { placed = { ...placed }; delete placed[g]; }
60
+ return { items, placed, picker };
61
+ },
62
+ };
63
+
64
+ function dropEmptyHost(tr, hostId, placedIds) {
65
+ if (!hostId) return;
66
+ let prev = null, hit = null;
67
+ tr.doc.forEach((n, off) => { if (n.attrs.id === hostId && prev && placedIds.includes(prev.attrs.id)) hit = { n, off }; prev = n; });
68
+ if (hit && hit.n.type === N.paragraph && hit.n.content.size === 0) tr.delete(hit.off, hit.off + hit.n.nodeSize);
69
+ }
70
+
71
+ export function placeTr(st, id, attrs) {
72
+ const { items, placed } = mediaKey.getState(st);
73
+ const it = items.find((x) => x.id === id);
74
+ if (!it) return null;
75
+ const at = boundary(st.doc, it.pos);
76
+ const blockId = newId();
77
+ const node = (it.kind === "image" ? N.image : N.file).create({ ...attrs, id: blockId, pos: null, parent: it.parent });
78
+ const tr = st.tr.insert(at, node);
79
+ if (!items.some((x) => x.group === it.group && x.id !== id)) dropEmptyHost(tr, it.replaceId, [...(placed[it.group] || []), blockId]);
80
+ return asStep(tr).setMeta(mediaKey, { placed: { id, at, size: node.nodeSize, blockId } });
81
+ }
82
+
83
+ export function removeTr(st, id) {
84
+ const { items, placed } = mediaKey.getState(st);
85
+ const it = items.find((x) => x.id === id);
86
+ if (!it) return null;
87
+ const tr = st.tr;
88
+ if (!items.some((x) => x.group === it.group && x.id !== id)) dropEmptyHost(tr, it.replaceId, placed[it.group] || []);
89
+ if (tr.docChanged) asStep(tr);
90
+ return tr.setMeta(mediaKey, { remove: id });
91
+ }
92
+
93
+ export function insertFiles(view, files, place = hostPlace(view.state, view.state.selection.$head.index(0))) {
94
+ files = [...files].filter(Boolean);
95
+ if (!files.length) return false;
96
+ const group = newId("g");
97
+ const add = files.map((file, order) => {
98
+ const kind = /^image\//.test(file.type || "") ? "image" : "file";
99
+ return {
100
+ id: newId("u"), pos: place.at, group, order, file, kind, name: file.name || (kind === "image" ? "image" : "file"),
101
+ parent: place.parent, replaceId: place.replaceId, state: "uploading", percent: 0,
102
+ preview: kind === "image" && globalThis.URL?.createObjectURL ? URL.createObjectURL(file) : null,
103
+ };
104
+ });
105
+ view.dispatch(view.state.tr.setMeta(mediaKey, { add }));
106
+ for (const it of add) run(view, it);
107
+ announce(view, files.length === 1 ? `Uploading ${add[0].name}` : `Uploading ${files.length} files`);
108
+ return true;
109
+ }
110
+
111
+ const live = (view, id) => mediaKey.getState(view.state).items.find((x) => x.id === id);
112
+ const update = (view, id, patch) => view.dispatch(view.state.tr.setMeta(mediaKey, { update: { id, ...patch } }));
113
+ const readDataUrl = (file) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => no(r.error); r.readAsDataURL(file); });
114
+ const attrsFor = (it, src) => (it.kind === "image" ? { src, alt: "", caption: "" } : { src, name: it.name, size: it.file.size ?? null, mime: it.file.type || null });
115
+
116
+ function settle(it, view, tr) {
117
+ if (it.preview) URL.revokeObjectURL?.(it.preview);
118
+ if (tr) view.dispatch(tr);
119
+ }
120
+
121
+ async function run(view, it) {
122
+ const rt = runtime.get(view);
123
+ const abort = new AbortController();
124
+ rt.aborts.set(it.id, abort);
125
+ let last = -5;
126
+ const onProgress = ({ percent }) => {
127
+ if (percent - last < 5 && percent !== 100) return;
128
+ last = percent;
129
+ if (runtime.has(view) && live(view, it.id)) update(view, it.id, { percent });
130
+ };
131
+ let res;
132
+ try { res = await rt.upload(it.file, { onProgress, signal: abort.signal }); }
133
+ catch { res = { code: "network", msg: "Upload failed" }; }
134
+ rt.aborts.delete(it.id);
135
+ if (!runtime.has(view) || !live(view, it.id)) return;
136
+ if (res?.ok) {
137
+ const src = safeFileHref(res.uploads?.[0]?.url);
138
+ if (src) {
139
+ settle(it, view, placeTr(view.state, it.id, attrsFor(it, src)));
140
+ announce(view, `${it.name} added`);
141
+ return;
142
+ }
143
+ res = { code: "bad-response", msg: "The upload returned an invalid address" };
144
+ }
145
+ if (!res || typeof res !== "object") res = { code: "bad-response", msg: "The upload returned an invalid response" };
146
+ if (res.code === "aborted") return settle(it, view, removeTr(view.state, it.id));
147
+ if (res.code === "unsupported") {
148
+ const canEmbed = /^image\/(png|jpeg|gif|webp)$/i.test(it.file.type) && it.file.size <= EMBED_MAX;
149
+ const message = canEmbed
150
+ ? "This host doesn't store files. You can keep this image inside the document, which makes the document larger."
151
+ : it.kind === "image" && it.file.size > EMBED_MAX ? "This host doesn't store files, and this image is over 1.5 MB, too large to keep inside the document." : "This host doesn't store files.";
152
+ update(view, it.id, { state: canEmbed ? "embed-choice" : "error", code: res.code, message });
153
+ announce(view, `${it.name}: ${message}`);
154
+ return;
155
+ }
156
+ const message = res.code === "timeout" ? "The upload timed out. It may have arrived; trying again is safe." : res.msg || "Upload failed";
157
+ update(view, it.id, { state: "error", code: res.code, message });
158
+ announce(view, `${it.name}: ${message}`);
159
+ }
160
+
161
+ export async function embedUpload(view, id) {
162
+ const it = live(view, id);
163
+ if (!it || it.state !== "embed-choice") return;
164
+ const src = await readDataUrl(it.file);
165
+ if (!runtime.has(view) || !live(view, id)) return;
166
+ settle(it, view, placeTr(view.state, id, attrsFor(it, src)));
167
+ announce(view, "Saved inside the document");
168
+ }
169
+
170
+ export function retryUpload(view, id) {
171
+ const it = live(view, id);
172
+ if (!it) return;
173
+ update(view, id, { state: "uploading", percent: 0, code: null, message: null });
174
+ run(view, live(view, id));
175
+ }
176
+
177
+ export function cancelUpload(view, id) {
178
+ const rt = runtime.get(view);
179
+ const abort = rt?.aborts.get(id);
180
+ const it = live(view, id);
181
+ if (it) settle(it, view, removeTr(view.state, id));
182
+ rt?.aborts.delete(id);
183
+ abort?.abort();
184
+ }
185
+
186
+ function button(doc, act, label, id) {
187
+ const b = doc.createElement("button");
188
+ b.type = "button";
189
+ b.className = "page-upload-btn";
190
+ b.dataset.act = act;
191
+ b.dataset.upload = id;
192
+ b.textContent = label;
193
+ return b;
194
+ }
195
+
196
+ function bookmarkDOM(doc, it) {
197
+ const box = doc.createElement("div");
198
+ box.className = "page-upload";
199
+ box.dataset.state = it.state;
200
+ box.dataset.kind = it.kind;
201
+ box.contentEditable = "false";
202
+ box.setAttribute("role", it.state === "uploading" ? "status" : "alert");
203
+ if (it.preview) {
204
+ const img = doc.createElement("img");
205
+ img.className = "page-upload-preview";
206
+ img.src = it.preview;
207
+ img.alt = "";
208
+ box.append(img);
209
+ }
210
+ const name = doc.createElement("span");
211
+ name.className = "page-upload-name";
212
+ name.textContent = it.name;
213
+ box.append(name);
214
+ if (it.state === "uploading") {
215
+ const bar = doc.createElement("span");
216
+ bar.className = "page-upload-bar";
217
+ bar.setAttribute("role", "progressbar");
218
+ bar.setAttribute("aria-label", `Uploading ${it.name}`);
219
+ bar.setAttribute("aria-valuemin", "0");
220
+ bar.setAttribute("aria-valuemax", "100");
221
+ bar.setAttribute("aria-valuenow", String(it.percent));
222
+ const fill = doc.createElement("span");
223
+ fill.style.width = `${it.percent}%`;
224
+ bar.append(fill);
225
+ box.append(bar, button(doc, "cancel", "Cancel", it.id));
226
+ return box;
227
+ }
228
+ const msg = doc.createElement("span");
229
+ msg.className = "page-upload-msg";
230
+ msg.textContent = it.message;
231
+ box.append(msg);
232
+ if (it.state === "embed-choice") box.append(button(doc, "embed", "Keep this image inside the document", it.id));
233
+ if (RETRY.has(it.code)) box.append(button(doc, "retry", "Retry", it.id));
234
+ box.append(button(doc, "remove", "Remove", it.id));
235
+ return box;
236
+ }
237
+
238
+ const htmlHasText = (html) => html.replace(/<(meta|style|img)\b[^>]*>/gi, "").replace(/<[^>]+>/g, "").trim() !== "";
239
+
240
+ export function mediaPlugin({ upload }) {
241
+ return new Plugin({
242
+ key: mediaKey,
243
+ state: mediaState,
244
+ view(view) {
245
+ const doc = view.dom.ownerDocument;
246
+ const input = doc.createElement("input");
247
+ input.type = "file";
248
+ input.multiple = true;
249
+ input.hidden = true;
250
+ input.className = "page-file-input";
251
+ view.dom.parentElement.append(input);
252
+ runtime.set(view, { upload, aborts: new Map(), input });
253
+ input.addEventListener("change", () => {
254
+ const picker = mediaKey.getState(view.state).picker;
255
+ view.dispatch(view.state.tr.setMeta(mediaKey, { picker: null }));
256
+ if (input.files?.length && picker) {
257
+ const index = indexOfId(view.state.doc, picker.place.hostId);
258
+ if (!picker.place.hostId || index >= 0) {
259
+ if (picker.remove) view.dispatch(asStep(view.state.tr.delete(picker.remove.from, picker.remove.to)));
260
+ insertFiles(view, input.files, index >= 0 ? hostPlace(view.state, index) : picker.place);
261
+ }
262
+ }
263
+ input.value = "";
264
+ view.focus();
265
+ });
266
+ input.addEventListener("cancel", () => view.dispatch(view.state.tr.setMeta(mediaKey, { picker: null })));
267
+ const onClick = (e) => {
268
+ const b = e.target.closest?.(".page-upload-btn");
269
+ if (!b) return;
270
+ e.preventDefault();
271
+ const id = b.dataset.upload;
272
+ if (b.dataset.act === "retry") retryUpload(view, id);
273
+ else if (b.dataset.act === "embed") embedUpload(view, id);
274
+ else cancelUpload(view, id);
275
+ };
276
+ view.dom.addEventListener("click", onClick);
277
+ return {
278
+ destroy() {
279
+ view.dom.removeEventListener("click", onClick);
280
+ const rt = runtime.get(view);
281
+ runtime.delete(view);
282
+ for (const it of mediaKey.getState(view.state).items) if (it.preview) URL.revokeObjectURL?.(it.preview);
283
+ for (const a of rt.aborts.values()) a.abort();
284
+ input.remove();
285
+ },
286
+ };
287
+ },
288
+ props: {
289
+ decorations(st) {
290
+ const { items } = mediaKey.getState(st);
291
+ if (!items.length) return null;
292
+ return DecorationSet.create(st.doc, items.map((it) => Decoration.widget(boundary(st.doc, it.pos), (view) => bookmarkDOM(view.dom.ownerDocument, it), {
293
+ side: -1000 + it.order, key: `${it.id}:${it.state}:${it.percent}`, stopEvent: () => true, ignoreSelection: true,
294
+ })));
295
+ },
296
+ handlePaste(view, event) {
297
+ const data = event.clipboardData;
298
+ const files = [...(data?.files ?? [])];
299
+ if (!files.length || view.state.selection.$from.parent.type.spec.code) return false;
300
+ if (htmlHasText(data.getData("text/html") || "")) return false;
301
+ return insertFiles(view, files);
302
+ },
303
+ handleDrop(view, event, _slice, moved) {
304
+ const files = [...(event.dataTransfer?.files ?? [])];
305
+ if (moved || !files.length) return false;
306
+ const hit = view.posAtCoords({ left: event.clientX, top: event.clientY });
307
+ if (!hit) return false;
308
+ const ents = outline(view.state.doc);
309
+ const i = Math.min(view.state.doc.resolve(hit.pos).index(0), ents.length - 1);
310
+ const r = view.nodeDOM(ents[i].at)?.getBoundingClientRect?.();
311
+ const before = r && event.clientY < r.top + r.height / 2;
312
+ event.preventDefault();
313
+ return insertFiles(view, files, { at: before ? ents[i].at : ents[ents[i].subEnd].end, parent: ents[i].node.attrs.parent ?? null, replaceId: null });
314
+ },
315
+ },
316
+ });
317
+ }
318
+
319
+ export function pickFiles(view, accept, place = hostPlace(view.state, view.state.selection.$head.index(0)), remove = null) {
320
+ const rt = runtime.get(view);
321
+ if (!rt) return false;
322
+ const picker = { place, remove: remove && { ...remove, text: view.state.doc.textBetween(remove.from, remove.to) } };
323
+ view.dispatch(view.state.tr.setMeta(mediaKey, { picker }));
324
+ rt.input.accept = accept;
325
+ rt.input.click();
326
+ return true;
327
+ }
328
+
329
+ const MIN_WIDTH = 48;
330
+
331
+ const ALIGNS = [
332
+ ["left", "Align left", '<path d="M2.5 3.5h11M2.5 6.5h6M2.5 9.5h11M2.5 12.5h6"/>'],
333
+ ["center", "Centre", '<path d="M2.5 3.5h11M5 6.5h6M2.5 9.5h11M5 12.5h6"/>'],
334
+ ["right", "Align right", '<path d="M2.5 3.5h11M7.5 6.5h6M2.5 9.5h11M7.5 12.5h6"/>'],
335
+ ];
336
+
337
+ export class ImageView {
338
+ constructor(node, view, getPos) {
339
+ this.node = node;
340
+ this.view = view;
341
+ this.getPos = getPos;
342
+ const doc = view.dom.ownerDocument;
343
+ this.dom = doc.createElement("figure");
344
+ this.dom.className = "page-image";
345
+ this.dom.addEventListener("dragstart", (event) => { event.preventDefault(); event.stopPropagation(); });
346
+ this.dom.draggable = false;
347
+ this.frame = doc.createElement("div");
348
+ this.frame.className = "page-image-frame";
349
+ this.img = doc.createElement("img");
350
+ this.img.draggable = false;
351
+ this.img.addEventListener("error", () => this.dom.toggleAttribute("data-broken", true));
352
+ this.img.addEventListener("load", () => this.dom.removeAttribute("data-broken"));
353
+ this.handle = doc.createElement("span");
354
+ this.handle.className = "page-image-handle";
355
+ this.handle.setAttribute("aria-hidden", "true");
356
+ this.handle.addEventListener("pointerdown", (e) => this.resize(e));
357
+ this.alignBar = doc.createElement("div");
358
+ this.alignBar.className = "page-image-align";
359
+ this.alignBar.setAttribute("role", "toolbar");
360
+ this.alignBar.setAttribute("aria-label", "Image alignment");
361
+ for (const [value, label, path] of ALIGNS) {
362
+ const b = doc.createElement("button");
363
+ b.type = "button";
364
+ b.dataset.align = value;
365
+ b.setAttribute("aria-label", label);
366
+ b.title = label;
367
+ b.innerHTML = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">${path}</svg>`;
368
+ this.alignBar.append(b);
369
+ }
370
+ this.alignBar.addEventListener("mousedown", (e) => e.preventDefault());
371
+ this.alignBar.addEventListener("click", (e) => {
372
+ const b = e.target.closest("[data-align]");
373
+ if (b) this.setAlign(b.dataset.align === "center" ? null : b.dataset.align);
374
+ });
375
+ this.frame.append(this.img, this.handle, this.alignBar);
376
+ this.caption = doc.createElement("figcaption");
377
+ this.caption.className = "page-image-caption";
378
+ this.caption.contentEditable = "plaintext-only";
379
+ this.caption.setAttribute("aria-label", "Caption");
380
+ this.caption.dataset.placeholder = "Add a caption";
381
+ this.caption.addEventListener("keydown", (e) => {
382
+ if (e.isComposing || this.view.composing) return;
383
+ if (e.key === "Enter") { e.preventDefault(); this.commitCaption(); this.view.focus(); }
384
+ if (e.key === "Escape") { e.preventDefault(); this.caption.textContent = this.node.attrs.caption; this.view.focus(); }
385
+ });
386
+ this.caption.addEventListener("blur", () => this.commitCaption());
387
+ this.caption.addEventListener("input", () => this.caption.toggleAttribute("data-empty", !this.caption.textContent));
388
+ this.dom.append(this.frame, this.caption);
389
+ this.render(node);
390
+ }
391
+
392
+ render(node) {
393
+ this.node = node;
394
+ this.dom.dataset.id = node.attrs.id ?? "";
395
+ if (this.img.getAttribute("src") !== node.attrs.src) this.img.src = node.attrs.src;
396
+ this.img.alt = node.attrs.alt || "";
397
+ this.frame.style.width = node.attrs.width ? `${node.attrs.width}px` : "";
398
+ if (node.attrs.align) this.dom.dataset.align = node.attrs.align;
399
+ else delete this.dom.dataset.align;
400
+ for (const b of this.alignBar.children) b.setAttribute("aria-pressed", String((node.attrs.align ?? "center") === b.dataset.align));
401
+ if (this.dom.ownerDocument.activeElement !== this.caption) this.caption.textContent = node.attrs.caption || "";
402
+ this.caption.toggleAttribute("data-empty", !node.attrs.caption);
403
+ }
404
+
405
+ update(node) {
406
+ if (node.type !== this.node.type) return false;
407
+ this.render(node);
408
+ return true;
409
+ }
410
+
411
+ setAlign(align) {
412
+ const pos = this.getPos();
413
+ if (pos == null || align === (this.node.attrs.align ?? null)) return;
414
+ this.view.dispatch(asStep(this.view.state.tr.setNodeMarkup(pos, null, { ...this.node.attrs, align })));
415
+ }
416
+
417
+ commitCaption() {
418
+ const text = this.caption.textContent.replace(/\s+/g, " ").trim();
419
+ const pos = this.getPos();
420
+ if (pos == null || text === (this.node.attrs.caption || "")) return;
421
+ this.view.dispatch(asStep(this.view.state.tr.setNodeMarkup(pos, null, { ...this.node.attrs, caption: text })));
422
+ }
423
+
424
+ resize(event) {
425
+ event.preventDefault();
426
+ event.stopPropagation();
427
+ const col = this.view.dom.getBoundingClientRect().width;
428
+ const start = this.frame.getBoundingClientRect().width;
429
+ const rtl = this.dom.ownerDocument.defaultView.getComputedStyle(this.dom).direction === "rtl";
430
+ const x0 = event.clientX;
431
+ const target = event.currentTarget;
432
+ target.setPointerCapture?.(event.pointerId);
433
+ let width = this.node.attrs.width;
434
+ const move = (e) => {
435
+ const dx = (e.clientX - x0) * (rtl ? -1 : 1);
436
+ width = Math.max(MIN_WIDTH, Math.min(Math.floor(col) || MIN_WIDTH, Math.round(start + dx)));
437
+ this.frame.style.width = `${width}px`;
438
+ };
439
+ const done = (commit) => {
440
+ target.removeEventListener("pointermove", move);
441
+ target.removeEventListener("pointerup", up);
442
+ target.removeEventListener("pointercancel", cancel);
443
+ const pos = this.getPos();
444
+ if (commit && pos != null && width !== this.node.attrs.width) this.view.dispatch(asStep(this.view.state.tr.setNodeMarkup(pos, null, { ...this.node.attrs, width })));
445
+ else this.frame.style.width = this.node.attrs.width ? `${this.node.attrs.width}px` : "";
446
+ };
447
+ const up = () => done(true);
448
+ const cancel = () => done(false);
449
+ target.addEventListener("pointermove", move);
450
+ target.addEventListener("pointerup", up);
451
+ target.addEventListener("pointercancel", cancel);
452
+ }
453
+
454
+ selectNode() { this.dom.classList.add("is-selected"); }
455
+ deselectNode() { this.dom.classList.remove("is-selected"); }
456
+ stopEvent(event) { return this.caption.contains(event.target) || this.alignBar.contains(event.target) || event.target.classList?.contains("page-image-handle"); }
457
+ ignoreMutation() { return true; }
458
+ }
459
+
460
+ const sizeLabel = (bytes) => {
461
+ if (bytes == null) return "";
462
+ if (bytes < 1024) return `${bytes} B`;
463
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
464
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
465
+ };
466
+
467
+ export class FileView {
468
+ constructor(node, view) {
469
+ this.node = node;
470
+ const doc = view.dom.ownerDocument;
471
+ this.dom = doc.createElement("div");
472
+ this.dom.className = "page-file";
473
+ this.dom.addEventListener("dragstart", (event) => { event.preventDefault(); event.stopPropagation(); });
474
+ this.link = doc.createElement("a");
475
+ this.link.className = "page-file-link";
476
+ this.link.target = "_blank";
477
+ this.link.rel = "noopener noreferrer";
478
+ this.icon = doc.createElement("span");
479
+ this.icon.className = "page-file-icon";
480
+ this.icon.textContent = "⎘";
481
+ this.name = doc.createElement("span");
482
+ this.name.className = "page-file-name";
483
+ this.meta = doc.createElement("span");
484
+ this.meta.className = "page-file-meta";
485
+ this.link.append(this.icon, this.name, this.meta);
486
+ this.dom.append(this.link);
487
+ this.render(node);
488
+ }
489
+
490
+ render(node) {
491
+ this.node = node;
492
+ this.dom.dataset.id = node.attrs.id ?? "";
493
+ const href = safeHref(node.attrs.src);
494
+ if (href) this.link.setAttribute("href", href);
495
+ else this.link.removeAttribute("href");
496
+ this.name.textContent = node.attrs.name || "file";
497
+ this.meta.textContent = sizeLabel(node.attrs.size);
498
+ }
499
+
500
+ update(node) {
501
+ if (node.type !== this.node.type) return false;
502
+ this.render(node);
503
+ return true;
504
+ }
505
+
506
+ selectNode() { this.dom.classList.add("is-selected"); }
507
+ deselectNode() { this.dom.classList.remove("is-selected"); }
508
+ stopEvent(event) { return this.link.contains(event.target); }
509
+ ignoreMutation() { return true; }
510
+ }
511
+
512
+ const indexOfId = (doc, id) => {
513
+ let found = -1;
514
+ doc.forEach((n, _, i) => { if (n.attrs.id === id) found = i; });
515
+ return found;
516
+ };
517
+
518
+ export function setImageAttrs(view, id, patch) {
519
+ const st = view.state;
520
+ const index = indexOfId(st.doc, id);
521
+ if (index < 0) return false;
522
+ let at = 0;
523
+ for (let i = 0; i < index; i++) at += st.doc.child(i).nodeSize;
524
+ const node = st.doc.child(index);
525
+ view.dispatch(asStep(st.tr.setNodeMarkup(at, null, { ...node.attrs, ...patch })));
526
+ return true;
527
+ }
528
+
529
+ export function openAltEditor(view, id) {
530
+ const index = indexOfId(view.state.doc, id);
531
+ if (index < 0) return false;
532
+ const shell = view.dom.parentElement;
533
+ const doc = shell.ownerDocument;
534
+ let at = 0;
535
+ for (let i = 0; i < index; i++) at += view.state.doc.child(i).nodeSize;
536
+ const box = doc.createElement("div");
537
+ box.className = "page-link";
538
+ box.dataset.mode = "alt";
539
+ const input = doc.createElement("input");
540
+ input.className = "page-link-input";
541
+ input.type = "text";
542
+ input.value = view.state.doc.child(index).attrs.alt || "";
543
+ input.placeholder = "Describe the image";
544
+ input.setAttribute("aria-label", "Alt text");
545
+ box.append(input);
546
+ const rect = view.nodeDOM(at)?.getBoundingClientRect?.();
547
+ const shellBox = shell.getBoundingClientRect();
548
+ if (rect) {
549
+ box.style.setProperty("--page-pop-x", `${rect.left - shellBox.left}px`);
550
+ box.style.setProperty("--page-pop-y", `${rect.bottom - shellBox.top + 6}px`);
551
+ }
552
+ shell.append(box);
553
+ let done = false;
554
+ const close = (commit, refocus = true) => {
555
+ if (done) return;
556
+ done = true;
557
+ const value = input.value.trim();
558
+ box.remove();
559
+ const i = indexOfId(view.state.doc, id);
560
+ if (commit && i >= 0 && value !== view.state.doc.child(i).attrs.alt) setImageAttrs(view, id, { alt: value });
561
+ if (refocus) view.focus();
562
+ };
563
+ input.addEventListener("keydown", (e) => {
564
+ if (e.key === "Enter") { e.preventDefault(); close(true); }
565
+ else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(false); }
566
+ });
567
+ input.addEventListener("blur", () => close(true, false));
568
+ input.focus();
569
+ return true;
570
+ }