@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,1121 @@
1
+ // Flat lists and toggles for the page editor: the outline a flat document
2
+ // implies, the structure plugin that keeps `parent` and `indent` valid after
3
+ // local edits, the list, toggle and move commands, the collapse plugin and
4
+ // paste. Spec: plans/hyperclay/workspace-positioning/seats/default-page/v1/s3/MERGED-S3.md
5
+ import { model, state, view as pmView, commands, history } from "../vendor/prosemirror.vendor.js";
6
+ import { schema } from "./schema.js";
7
+ import { REMOTE, newId } from "./model.js";
8
+ import { safeHref, safeSrc } from "./link.js";
9
+
10
+ const alignOf = (a) => (a === "left" || a === "right" ? a : null);
11
+
12
+ const { Plugin, PluginKey, TextSelection, NodeSelection, Selection } = state;
13
+ const N = schema.nodes;
14
+ const isList = (n) => n.type === N.list_item;
15
+ const isToggle = (n) => n.type === N.toggle;
16
+ const parentOf = (n) => n.attrs.parent ?? null;
17
+
18
+ // Move, Tab and Shift-Tab are each their own undo step: the transaction closes
19
+ // the history group before it, and carries STEP so the editor closes the group
20
+ // after it too (createPageEditor).
21
+ export const STEP = "page-step";
22
+ export const MOVE = "page-move";
23
+ const asStep = (dispatch) => dispatch && ((tr) => dispatch(history.closeHistory(tr).setMeta(STEP, true)));
24
+
25
+ // ---- the outline ------------------------------------------------------------
26
+ //
27
+ // One entry per top-level block, in document order:
28
+ // at, end the block's start and end position
29
+ // up index of its outline parent, or -1. A list item with indent > 0
30
+ // hangs under the nearest earlier item of its run with a smaller
31
+ // indent; every other block hangs under the toggle named by parent.
32
+ // depth how many toggles it sits inside
33
+ // hidden some toggle above it is closed
34
+ // subEnd index of the last block of its subtree (itself when it has none)
35
+ // A subtree is always contiguous, so it is the range [ents[i].at, ents[subEnd].end].
36
+ export function outline(doc) {
37
+ const ents = [];
38
+ const toggleAt = new Map();
39
+ let run = [];
40
+ doc.forEach((node, at) => {
41
+ const i = ents.length;
42
+ const prev = ents[i - 1];
43
+ const parent = parentOf(node);
44
+ const t = parent !== null && toggleAt.has(parent) ? toggleAt.get(parent) : -1;
45
+ let up = t;
46
+ if (isList(node)) {
47
+ if (!(prev && isList(prev.node) && parentOf(prev.node) === parent)) run = [];
48
+ while (run.length && ents[run[run.length - 1]].node.attrs.indent >= node.attrs.indent) run.pop();
49
+ if (run.length) up = run[run.length - 1];
50
+ run.push(i);
51
+ } else run = [];
52
+ const hidden = t >= 0 && (ents[t].hidden || !ents[t].node.attrs.open);
53
+ ents.push({ node, at, end: at + node.nodeSize, i, up, depth: t >= 0 ? ents[t].depth + 1 : 0, hidden, subEnd: i });
54
+ if (isToggle(node) && node.attrs.id) toggleAt.set(node.attrs.id, i);
55
+ });
56
+ for (let j = ents.length - 1; j >= 0; j--) {
57
+ const u = ents[j].up;
58
+ if (u >= 0 && ents[j].subEnd > ents[u].subEnd) ents[u].subEnd = ents[j].subEnd;
59
+ }
60
+ return ents;
61
+ }
62
+
63
+ export const subtreeRange = (ents, i) => ({ from: ents[i].at, to: ents[ents[i].subEnd].end });
64
+
65
+ // ---- structure: parent and indent stay valid after any local edit -----------
66
+ //
67
+ // Rules, one forward pass:
68
+ // 1. A block's parent is null or a toggle on the chain the previous block
69
+ // opens: the previous block's own toggle ancestors, plus the previous block
70
+ // itself when it is a toggle. An invalid parent climbs its own chain
71
+ // (through the old document for a toggle just deleted) until it is valid.
72
+ // 2. If the next block with a different parent names one deeper on that
73
+ // chain, this block moves down to it, so blocks inserted inside a body
74
+ // (one, or a pasted run of them) join the body instead of cutting it.
75
+ // 3. In a run of list items (contiguous items with one parent) the first has
76
+ // indent 0 and each other at most the previous one's + 1. When an item is
77
+ // clamped, its whole subtree moves by the same amount and its old siblings
78
+ // move with it, so deleting a parent item promotes its children one level.
79
+ // A valid document comes back with no fixes.
80
+ export function structureFixes(doc, oldDoc = null) {
81
+ const declared = new Map();
82
+ oldDoc?.forEach((n) => { if (n.attrs.id) declared.set(n.attrs.id, parentOf(n)); });
83
+ doc.forEach((n) => { if (n.attrs.id) declared.set(n.attrs.id, parentOf(n)); });
84
+ const nodes = [];
85
+ doc.forEach((node, at) => nodes.push({ node, at }));
86
+ const ahead = new Array(nodes.length).fill(null);
87
+ for (let i = nodes.length - 2; i >= 0; i--) {
88
+ const next = parentOf(nodes[i + 1].node);
89
+ ahead[i] = next !== parentOf(nodes[i].node) ? next : ahead[i + 1];
90
+ }
91
+ const fixes = [];
92
+ let allowed = [null];
93
+ let prev = null;
94
+ let stack = [];
95
+ nodes.forEach(({ node, at }, i) => {
96
+ let p = parentOf(node);
97
+ const seen = new Set();
98
+ while (p !== null && !allowed.includes(p) && !seen.has(p)) { seen.add(p); p = declared.has(p) ? declared.get(p) : null; }
99
+ if (!allowed.includes(p)) p = null;
100
+ const q = ahead[i];
101
+ if (allowed.indexOf(q) > allowed.indexOf(p)) p = q;
102
+ const chain = allowed.slice(0, allowed.indexOf(p) + 1);
103
+
104
+ let indent = node.attrs.indent;
105
+ if (isList(node)) {
106
+ const d = Number.isInteger(indent) && indent > 0 ? indent : 0;
107
+ const continues = prev && isList(prev.node) && prev.parent === p;
108
+ if (!continues) stack = [];
109
+ while (stack.length && stack[stack.length - 1].orig > d) stack.pop();
110
+ let shift = 0;
111
+ if (stack.length) {
112
+ const top = stack[stack.length - 1];
113
+ shift = top.shift;
114
+ if (top.orig === d) stack.pop();
115
+ }
116
+ indent = Math.max(0, Math.min(d - shift, continues ? prev.indent + 1 : 0));
117
+ stack.push({ orig: d, shift: d - indent });
118
+ } else stack = [];
119
+
120
+ if (p !== parentOf(node) || (isList(node) && indent !== node.attrs.indent)) {
121
+ fixes.push({ at, attrs: { ...node.attrs, parent: p, ...(isList(node) ? { indent } : {}) } });
122
+ }
123
+ prev = { node, parent: p, indent };
124
+ allowed = isToggle(node) && node.attrs.id ? [...chain, node.attrs.id] : chain;
125
+ });
126
+ return fixes;
127
+ }
128
+
129
+ export function structurePlugin() {
130
+ return new Plugin({
131
+ appendTransaction(trs, old, next) {
132
+ if (!trs.some((tr) => tr.docChanged && !tr.getMeta(REMOTE))) return null;
133
+ const fixes = structureFixes(next.doc, old.doc);
134
+ if (!fixes.length) return null;
135
+ const tr = next.tr;
136
+ for (const f of fixes) tr.setNodeMarkup(f.at, null, f.attrs);
137
+ return tr;
138
+ },
139
+ });
140
+ }
141
+
142
+ // ---- spans ------------------------------------------------------------------
143
+
144
+ // The top-level indices the selection touches.
145
+ function touched(doc, sel, ents) {
146
+ const a = doc.resolve(sel.from).index(0);
147
+ let b = Math.min(doc.resolve(sel.to).index(0), ents.length - 1);
148
+ if (b > a && sel.to <= ents[b].at) b--;
149
+ return [Math.min(a, ents.length - 1), b];
150
+ }
151
+ export const touchedBlocks = touched;
152
+
153
+ // The selection lifted to whole sibling subtrees: [lo, hi] are block indices,
154
+ // every root in it shares the outline parent `up`.
155
+ export function siblingSpan(ents, a, b) {
156
+ let lo = a;
157
+ while (ents[lo].up >= 0 && b > ents[ents[lo].up].subEnd) lo = ents[lo].up;
158
+ const up = ents[lo].up;
159
+ let hi = ents[lo].subEnd;
160
+ while (hi < b) hi = ents[hi + 1].subEnd;
161
+ return { lo, hi, up };
162
+ }
163
+
164
+ // ---- block selection --------------------------------------------------------
165
+
166
+ // A selection of whole top-level blocks. anchor and head are block boundaries
167
+ // (depth-0 positions), so from..to covers whole blocks; the commands lift it to
168
+ // whole sibling subtrees (siblingSpan), so a selected toggle carries its body.
169
+ // It is invisible to ProseMirror (the DOM selection is hidden) and drawn with
170
+ // node decorations by blocksel.js.
171
+ const boundary = (doc, pos, side) => {
172
+ const $p = doc.resolve(Math.max(0, Math.min(pos, doc.content.size)));
173
+ if ($p.depth === 0) return $p.pos;
174
+ return side < 0 ? $p.before(1) : $p.after(1);
175
+ };
176
+ const blockRef = (e) => ({ id: e.node.attrs.id, at: e.at, node: e.node });
177
+ const matchesRef = (e, ref) => ref.id ? e.node.attrs.id === ref.id : e.at === ref.at && e.node.type === ref.node.type && e.node.content.eq(ref.node.content);
178
+
179
+ export function captureSpan(ents, lo, hi) {
180
+ const roots = [];
181
+ for (let i = lo; i <= hi; i = ents[i].subEnd + 1) roots.push(blockRef(ents[i]));
182
+ return { roots, members: ents.slice(lo, hi + 1).map(blockRef) };
183
+ }
184
+
185
+ export function resolveSpan(ents, span) {
186
+ const roots = span.roots.map((ref) => ents.findIndex((e) => matchesRef(e, ref))).filter((i) => i >= 0);
187
+ if (!roots.length || roots.some((i) => ents[i].hidden)) return null;
188
+ const lo = roots[0], up = ents[lo].up;
189
+ let hi = ents[lo].subEnd;
190
+ for (const i of roots.slice(1)) {
191
+ if (i !== hi + 1 || ents[i].up !== up) return null;
192
+ hi = ents[i].subEnd;
193
+ }
194
+ if (ents.slice(lo, hi + 1).some((e) => !span.members.some((ref) => matchesRef(e, ref)))) return null;
195
+ return { lo, hi };
196
+ }
197
+
198
+ const mapSpan = (span, mapping) => {
199
+ const map = (ref) => ({ ...ref, at: mapping.map(ref.at, 1) });
200
+ return { roots: span.roots.map(map), members: span.members.map(map) };
201
+ };
202
+
203
+ export class BlockSelection extends Selection {
204
+ map(doc, mapping) {
205
+ return this.getBookmark().map(mapping).resolve(doc);
206
+ }
207
+ eq(other) {
208
+ return other instanceof BlockSelection && other.anchor === this.anchor && other.head === this.head;
209
+ }
210
+ toJSON() {
211
+ return { type: "page-block", anchor: this.anchor, head: this.head };
212
+ }
213
+ static fromJSON(doc, json) {
214
+ return new BlockSelection(doc.resolve(json.anchor), doc.resolve(json.head));
215
+ }
216
+ getBookmark() {
217
+ const { lo, hi } = this.span();
218
+ return new BlockBookmark(this.anchor, this.head, captureSpan(outline(this.$from.doc), lo, hi));
219
+ }
220
+ span(doc = this.$from.doc) {
221
+ return { lo: doc.resolve(this.from).index(0), hi: doc.resolve(this.to).index(0) - 1 };
222
+ }
223
+ }
224
+ BlockSelection.prototype.visible = false;
225
+ Selection.jsonID("page-block", BlockSelection);
226
+
227
+ class BlockBookmark {
228
+ constructor(anchor, head, span) { this.anchor = anchor; this.head = head; this.span = span; }
229
+ map(mapping) {
230
+ const fwd = this.anchor <= this.head;
231
+ return new BlockBookmark(mapping.map(this.anchor, fwd ? 1 : -1), mapping.map(this.head, fwd ? -1 : 1), mapSpan(this.span, mapping));
232
+ }
233
+ resolve(doc) {
234
+ const ents = outline(doc), span = resolveSpan(ents, this.span);
235
+ if (!span) {
236
+ const first = ents.find((e) => matchesRef(e, this.span.roots[0]));
237
+ return Selection.near(doc.resolve(first?.at ?? boundary(doc, Math.min(this.anchor, this.head), -1)));
238
+ }
239
+ const from = ents[span.lo].at, to = ents[span.hi].end;
240
+ return this.anchor <= this.head ? new BlockSelection(doc.resolve(from), doc.resolve(to)) : new BlockSelection(doc.resolve(to), doc.resolve(from));
241
+ }
242
+ }
243
+
244
+ const prevSibling = (ents, i) => {
245
+ for (let j = i - 1; j >= 0; j--) if (ents[j].up === ents[i].up) return j;
246
+ return -1;
247
+ };
248
+
249
+ // Move blocks lo..hi so they start at `target` (a block boundary of the
250
+ // current doc, outside the span). Moved nodes get pos null: identityPlugin
251
+ // then gives exactly those blocks new keys between their new neighbours and
252
+ // leaves every other key alone. `attrs(ent)` returns attr changes per block.
253
+ export function moveIntent(ents, lo, hi, target, attrs = () => null) {
254
+ const root = attrs(ents[lo]) || {};
255
+ const parent = "parent" in root ? root.parent : parentOf(ents[lo].node);
256
+ let afterId = null;
257
+ for (let k = 0; k < ents.length && ents[k].end <= target; k++) {
258
+ if (k >= lo && k <= hi) continue;
259
+ if (parentOf(ents[k].node) === parent) afterId = ents[k].node.attrs.id;
260
+ }
261
+ const ids = [], changes = {};
262
+ for (let k = lo; k <= hi; k++) {
263
+ const n = ents[k].node, c = attrs(ents[k]);
264
+ ids.push(n.attrs.id);
265
+ if (c && "indent" in c && c.indent !== n.attrs.indent) changes[n.attrs.id] = { indent: c.indent };
266
+ }
267
+ return { ids, afterId, parent, attrs: changes };
268
+ }
269
+
270
+ export function moveSpanTr(st, ents, lo, hi, target, attrs = () => null, sel = st.selection) {
271
+ const from = ents[lo].at, to = ents[hi].end, size = to - from;
272
+ const nodes = [];
273
+ for (let k = lo; k <= hi; k++) {
274
+ const n = ents[k].node;
275
+ nodes.push(n.type.create({ ...n.attrs, ...(attrs(ents[k]) || {}), pos: null }, n.content, n.marks));
276
+ }
277
+ const tr = st.tr.delete(from, to);
278
+ const at = target > from ? target - size : target;
279
+ tr.insert(at, nodes);
280
+ const shift = at - from;
281
+ if (sel instanceof BlockSelection) tr.setSelection(new BlockSelection(tr.doc.resolve(sel.anchor + shift), tr.doc.resolve(sel.head + shift)));
282
+ else if (sel instanceof NodeSelection) tr.setSelection(NodeSelection.create(tr.doc, sel.from + shift));
283
+ else tr.setSelection(TextSelection.create(tr.doc, sel.anchor + shift, sel.head + shift));
284
+ return tr.setMeta(MOVE, moveIntent(ents, lo, hi, target, attrs));
285
+ }
286
+
287
+ // Attr changes that carry a span out of its outline parent P to P's level.
288
+ function leaveParent(ents, lo, up) {
289
+ const p = ents[up].node;
290
+ if (isToggle(p)) {
291
+ const from = p.attrs.id, to = parentOf(p);
292
+ return (e) => (parentOf(e.node) === from ? { parent: to } : null);
293
+ }
294
+ const delta = p.attrs.indent - ents[lo].node.attrs.indent;
295
+ return (e) => (isList(e.node) ? { indent: e.node.attrs.indent + delta } : null);
296
+ }
297
+
298
+ // ---- move a block with its subtree ------------------------------------------
299
+ //
300
+ // dir -1 / +1. The unit is the selection lifted to whole sibling subtrees. It
301
+ // swaps with the previous / next sibling subtree; at the edge of its parent it
302
+ // steps out and lands just above / below the parent, at the parent's level.
303
+ export const moveBlock = (dir) => (st, dispatch) => {
304
+ dispatch = asStep(dispatch);
305
+ const ents = outline(st.doc);
306
+ if (!ents.length) return false;
307
+ const [a, b] = touched(st.doc, st.selection, ents);
308
+ const { lo, hi, up } = siblingSpan(ents, a, b);
309
+ let target, attrs = () => null;
310
+ if (dir < 0) {
311
+ const s = prevSibling(ents, lo);
312
+ if (s >= 0) target = ents[s].at;
313
+ else if (up >= 0) { target = ents[up].at; attrs = leaveParent(ents, lo, up); }
314
+ else return true;
315
+ } else {
316
+ const next = hi + 1;
317
+ if (next < ents.length && ents[next].up === up) target = ents[ents[next].subEnd].end;
318
+ else if (up >= 0) { target = ents[ents[up].subEnd].end; attrs = leaveParent(ents, lo, up); }
319
+ else return true;
320
+ }
321
+ if (dispatch) dispatch(moveSpanTr(st, ents, lo, hi, target, attrs).scrollIntoView());
322
+ return true;
323
+ };
324
+
325
+ // ---- Tab / Shift-Tab --------------------------------------------------------
326
+
327
+ export const MAX_INDENT = 8;
328
+ const sameRun = (x, y) => isList(x.node) && isList(y.node) && parentOf(x.node) === parentOf(y.node);
329
+
330
+ export function indentBlocks(st, dispatch) {
331
+ dispatch = asStep(dispatch);
332
+ const ents = outline(st.doc);
333
+ if (!ents.length) return false;
334
+ const [a, b] = touched(st.doc, st.selection, ents);
335
+ const first = ents[a];
336
+ if (isList(first.node) && a > 0 && sameRun(ents[a - 1], first)) {
337
+ if (first.node.attrs.indent > ents[a - 1].node.attrs.indent) return true;
338
+ for (let k = a; k <= Math.max(b, first.subEnd); k++) if (ents[k].node.attrs.indent >= MAX_INDENT) return true;
339
+ if (dispatch) {
340
+ const tr = st.tr;
341
+ let end = b;
342
+ for (let k = a; k <= end; k++) end = Math.max(end, ents[k].subEnd);
343
+ for (let k = a; k <= end && sameRun(ents[k], first); k++) {
344
+ tr.setNodeMarkup(ents[k].at, null, { ...ents[k].node.attrs, indent: ents[k].node.attrs.indent + 1 });
345
+ }
346
+ dispatch(tr);
347
+ }
348
+ return true;
349
+ }
350
+ const s = prevSibling(ents, a);
351
+ if (s < 0 || !isToggle(ents[s].node) || ents[s].subEnd !== a - 1) return true;
352
+ if (dispatch) {
353
+ const { hi } = siblingSpan(ents, a, b);
354
+ const tr = st.tr, from = parentOf(first.node), to = ents[s].node.attrs.id;
355
+ for (let k = a; k <= hi; k++) {
356
+ if (parentOf(ents[k].node) === from) tr.setNodeMarkup(ents[k].at, null, { ...ents[k].node.attrs, parent: to });
357
+ }
358
+ if (!ents[s].node.attrs.open) tr.setNodeMarkup(ents[s].at, null, { ...ents[s].node.attrs, open: true });
359
+ dispatch(tr);
360
+ }
361
+ return true;
362
+ }
363
+
364
+ export function outdentBlocks(st, dispatch) {
365
+ dispatch = asStep(dispatch);
366
+ const ents = outline(st.doc);
367
+ if (!ents.length) return false;
368
+ const [a, b] = touched(st.doc, st.selection, ents);
369
+ const first = ents[a];
370
+ if (isList(first.node) && first.node.attrs.indent > 0) {
371
+ if (dispatch) {
372
+ const tr = st.tr;
373
+ for (let k = a; k <= b;) {
374
+ const e = ents[k];
375
+ if (isList(e.node) && e.node.attrs.indent > 0) {
376
+ for (let m = k; m <= e.subEnd; m++) {
377
+ if (isList(ents[m].node)) tr.setNodeMarkup(ents[m].at, null, { ...ents[m].node.attrs, indent: ents[m].node.attrs.indent - 1 });
378
+ }
379
+ }
380
+ k = e.subEnd + 1;
381
+ }
382
+ dispatch(tr);
383
+ }
384
+ return true;
385
+ }
386
+ const t = parentOf(first.node);
387
+ if (t === null) return true;
388
+ const { lo, hi } = siblingSpan(ents, a, b);
389
+ let up = ents[lo].up;
390
+ while (up >= 0 && !isToggle(ents[up].node)) up = ents[up].up;
391
+ if (up < 0) return true;
392
+ if (dispatch) {
393
+ const attrs = leaveParent(ents, lo, up);
394
+ const end = ents[up].subEnd;
395
+ if (hi === end) {
396
+ const tr = st.tr;
397
+ for (let k = lo; k <= hi; k++) { const c = attrs(ents[k]); if (c) tr.setNodeMarkup(ents[k].at, null, { ...ents[k].node.attrs, ...c }); }
398
+ dispatch(tr);
399
+ } else dispatch(moveSpanTr(st, ents, lo, hi, ents[end].end, attrs).scrollIntoView());
400
+ }
401
+ return true;
402
+ }
403
+
404
+ // ---- Enter ------------------------------------------------------------------
405
+
406
+ function deleteFirst(st) {
407
+ const tr = st.tr;
408
+ if (!st.selection.empty) tr.deleteSelection();
409
+ return tr;
410
+ }
411
+
412
+ export function enterListItem(st, dispatch) {
413
+ const { $from, $to, empty } = st.selection;
414
+ if ($from.depth !== 1 || !isList($from.parent) || !$from.sameParent($to)) return false;
415
+ if (empty && $from.parent.content.size === 0) {
416
+ const n = $from.parent;
417
+ if (n.attrs.indent > 0) return outdentBlocks(st, dispatch);
418
+ if (dispatch) dispatch(st.tr.setNodeMarkup($from.before(), N.paragraph, { id: n.attrs.id, pos: n.attrs.pos, parent: n.attrs.parent }));
419
+ return true;
420
+ }
421
+ const tr = deleteFirst(st);
422
+ const $pos = tr.selection.$from;
423
+ const node = $pos.parent;
424
+ const ents = outline(tr.doc);
425
+ const i = $pos.index(0);
426
+ const atStart = $pos.parentOffset === 0;
427
+ const atEnd = $pos.parentOffset === node.content.size;
428
+ const next = ents[i + 1];
429
+ const deeper = atEnd && next && sameRun(next, ents[i]) && next.node.attrs.indent > node.attrs.indent;
430
+ if (atStart) {
431
+ tr.split($pos.pos, 1, [{ type: N.list_item, attrs: node.attrs }]);
432
+ tr.setNodeMarkup(ents[i].at, null, { ...node.attrs, checked: false });
433
+ } else {
434
+ tr.split($pos.pos, 1, [{ type: N.list_item, attrs: { ...node.attrs, checked: false, indent: node.attrs.indent + (deeper ? 1 : 0) } }]);
435
+ }
436
+ if (dispatch) dispatch(tr.scrollIntoView());
437
+ return true;
438
+ }
439
+
440
+ export function enterToggle(st, dispatch) {
441
+ const { $from, $to } = st.selection;
442
+ if ($from.depth !== 1 || !isToggle($from.parent) || !$from.sameParent($to)) return false;
443
+ const tr = deleteFirst(st);
444
+ const $pos = tr.selection.$from;
445
+ const node = $pos.parent;
446
+ const ents = outline(tr.doc);
447
+ const i = $pos.index(0), e = ents[i];
448
+ const hasBody = e.subEnd > i;
449
+ if (node.content.size === 0 && !hasBody) {
450
+ tr.setNodeMarkup(e.at, N.paragraph, { id: node.attrs.id, pos: node.attrs.pos, parent: node.attrs.parent });
451
+ if (dispatch) dispatch(tr);
452
+ return true;
453
+ }
454
+ if ($pos.parentOffset === 0 && node.content.size > 0) {
455
+ tr.insert(e.at, N.paragraph.create({ parent: parentOf(node) }));
456
+ if (dispatch) dispatch(tr.scrollIntoView());
457
+ return true;
458
+ }
459
+ const rest = node.content.cut($pos.parentOffset);
460
+ if (node.attrs.open) {
461
+ tr.split($pos.pos, 1, [{ type: N.paragraph, attrs: { parent: node.attrs.id } }]);
462
+ } else {
463
+ const end = ents[e.subEnd].end;
464
+ const before = tr.steps.length;
465
+ tr.delete($pos.pos, e.end - 1);
466
+ const at = tr.mapping.slice(before).map(end);
467
+ tr.insert(at, N.toggle.create({ parent: parentOf(node), open: false }, rest));
468
+ tr.setSelection(TextSelection.create(tr.doc, at + 1));
469
+ }
470
+ if (dispatch) dispatch(tr.scrollIntoView());
471
+ return true;
472
+ }
473
+
474
+ // Enter in an empty paragraph that ends a toggle's body steps out of the toggle.
475
+ export function exitToggleOnEmpty(st, dispatch) {
476
+ const { $from, empty } = st.selection;
477
+ if (!empty || $from.depth !== 1 || $from.parent.type !== N.paragraph || $from.parent.content.size) return false;
478
+ const t = parentOf($from.parent);
479
+ if (t === null) return false;
480
+ const ents = outline(st.doc);
481
+ const i = $from.index(0);
482
+ const ti = ents.findIndex((x) => x.node.attrs.id === t);
483
+ if (ti < 0 || ents[ti].subEnd !== i) return false;
484
+ if (dispatch) dispatch(st.tr.setNodeMarkup(ents[i].at, null, { ...$from.parent.attrs, parent: parentOf(ents[ti].node) }));
485
+ return true;
486
+ }
487
+
488
+ // Enter at the end of a paragraph or heading inside a toggle body: the new
489
+ // paragraph stays in the same body (the default split would make a root block).
490
+ export function enterBodyBlock(st, dispatch) {
491
+ const { $from, empty } = st.selection;
492
+ if (!empty || $from.depth !== 1 || !$from.parent.isTextblock || $from.parent.type.spec.code) return false;
493
+ const n = $from.parent;
494
+ if (isList(n) || isToggle(n) || parentOf(n) === null || $from.parentOffset !== n.content.size) return false;
495
+ if (dispatch) {
496
+ const tr = st.tr.split($from.pos, 1, [{ type: N.paragraph, attrs: { parent: parentOf(n) } }]);
497
+ dispatch(tr.scrollIntoView());
498
+ }
499
+ return true;
500
+ }
501
+
502
+ // ---- Backspace / Delete -----------------------------------------------------
503
+
504
+ export function backspaceListItem(st, dispatch) {
505
+ const { $from, empty } = st.selection;
506
+ if (!empty || $from.depth !== 1 || !isList($from.parent) || $from.parentOffset !== 0) return false;
507
+ if ($from.parent.attrs.indent > 0) return outdentBlocks(st, dispatch);
508
+ const n = $from.parent;
509
+ if (dispatch) dispatch(st.tr.setNodeMarkup($from.before(), N.paragraph, { id: n.attrs.id, pos: n.attrs.pos, parent: n.attrs.parent }));
510
+ return true;
511
+ }
512
+
513
+ export function backspaceToggle(st, dispatch) {
514
+ const { $from, empty } = st.selection;
515
+ if (!empty || $from.depth !== 1 || !isToggle($from.parent) || $from.parentOffset !== 0) return false;
516
+ const n = $from.parent;
517
+ if (dispatch) dispatch(st.tr.setNodeMarkup($from.before(), N.paragraph, { id: n.attrs.id, pos: n.attrs.pos, parent: n.attrs.parent }));
518
+ return true;
519
+ }
520
+
521
+ // The collapsed toggle whose hidden body ends right before block i, if any.
522
+ function collapsedBefore(ents, i) {
523
+ if (i <= 0 || !ents[i - 1].hidden) return -1;
524
+ let j = i - 1;
525
+ while (j >= 0 && ents[j].hidden) j--;
526
+ return j >= 0 && isToggle(ents[j].node) && ents[j].subEnd >= i - 1 ? j : -1;
527
+ }
528
+
529
+ // Join a textblock's content onto the end of a summary line, leaving the body.
530
+ function joinIntoSummary(st, ents, ti, bi) {
531
+ const tr = st.tr, b = ents[bi], sumEnd = ents[ti].end - 1;
532
+ tr.delete(b.at, b.end);
533
+ if (b.node.content.size) tr.insert(sumEnd, b.node.content);
534
+ return tr.setSelection(TextSelection.create(tr.doc, sumEnd));
535
+ }
536
+
537
+ export function backspaceAfterCollapsed(st, dispatch) {
538
+ const { $from, empty } = st.selection;
539
+ if (!empty || $from.depth !== 1 || $from.parentOffset !== 0 || !$from.parent.isTextblock) return false;
540
+ const ents = outline(st.doc);
541
+ const i = $from.index(0), ti = collapsedBefore(ents, i);
542
+ if (ti < 0) return false;
543
+ if (dispatch) dispatch(joinIntoSummary(st, ents, ti, i).scrollIntoView());
544
+ return true;
545
+ }
546
+
547
+ export function deleteAtCollapsedEnd(st, dispatch) {
548
+ const { $from, empty } = st.selection;
549
+ if (!empty || $from.depth !== 1 || !isToggle($from.parent) || $from.parent.attrs.open) return false;
550
+ if ($from.parentOffset !== $from.parent.content.size) return false;
551
+ const ents = outline(st.doc);
552
+ const ti = $from.index(0), ni = ents[ti].subEnd + 1;
553
+ if (ni === ti + 1) return false;
554
+ if (ni >= ents.length || !ents[ni].node.isTextblock) return true;
555
+ if (dispatch) dispatch(joinIntoSummary(st, ents, ti, ni).scrollIntoView());
556
+ return true;
557
+ }
558
+
559
+ // Backspace at the start of a paragraph, heading or code block inside a toggle
560
+ // body moves it out of the toggle, as Shift-Tab does, instead of joining it to
561
+ // the line above. A list item there loses its list format first
562
+ // (backspaceListItem), then leaves on the next Backspace.
563
+ export function backspaceBodyBlock(st, dispatch) {
564
+ const { $from, empty } = st.selection;
565
+ if (!empty || $from.depth !== 1 || $from.parentOffset !== 0 || !$from.parent.isTextblock) return false;
566
+ const n = $from.parent;
567
+ if (isList(n) || isToggle(n) || parentOf(n) === null) return false;
568
+ return outdentBlocks(st, dispatch);
569
+ }
570
+
571
+ // ---- open / closed ----------------------------------------------------------
572
+
573
+ export function setOpen(st, ti, open, ents = outline(st.doc)) {
574
+ const e = ents[ti];
575
+ return st.tr.setNodeMarkup(e.at, null, { ...e.node.attrs, open }).setMeta("addToHistory", false);
576
+ }
577
+
578
+ export function toggleOpenAtSelection(st, dispatch) {
579
+ const { $from } = st.selection;
580
+ if ($from.depth !== 1 || !isToggle($from.parent)) return false;
581
+ if (dispatch) dispatch(setOpen(st, $from.index(0), !$from.parent.attrs.open));
582
+ return true;
583
+ }
584
+
585
+ // ---- hidden ranges and the selection guard ----------------------------------
586
+
587
+ // [from, to] of every hidden body, outermost only: positions strictly inside
588
+ // one are inside hidden blocks. Each carries the toggle's entry.
589
+ export function hiddenRanges(ents) {
590
+ const out = [];
591
+ for (const e of ents) {
592
+ if (!isToggle(e.node) || e.hidden || e.node.attrs.open || e.subEnd === e.i) continue;
593
+ out.push({ line: e.at + 1, sumEnd: e.end - 1, from: e.end, to: ents[e.subEnd].end });
594
+ }
595
+ return out;
596
+ }
597
+
598
+ const inside = (r, p) => p > r.from && p < r.to;
599
+
600
+ // Where a selection endpoint inside a hidden range goes: forward to the first
601
+ // visible text after it, backward to the end of the summary line.
602
+ function snap(doc, ranges, r, dir) {
603
+ if (dir > 0) {
604
+ let p = r.to;
605
+ for (;;) {
606
+ const found = Selection.findFrom(doc.resolve(p), 1, true);
607
+ if (!found) break;
608
+ const again = ranges.find((x) => inside(x, found.head));
609
+ if (!again) return found.head;
610
+ p = again.to;
611
+ }
612
+ }
613
+ return r.sumEnd;
614
+ }
615
+
616
+ // The selection with no endpoint inside a hidden body, and with a range that
617
+ // crosses a hidden body widened to start at that toggle's line, unless it
618
+ // starts exactly at the summary's end: then it starts after the body instead,
619
+ // so the toggle is not selected. Returns null when it is already fine.
620
+ // `dragging` defers the widening to mouseup.
621
+ export function guardSelection(doc, sel, ranges, dir = -1, dragging = false) {
622
+ if (!ranges.length) return null;
623
+ let { anchor, head } = sel;
624
+ if (!(sel instanceof TextSelection)) {
625
+ const r = ranges.find((x) => sel.from >= x.from && sel.from < x.to);
626
+ return r ? TextSelection.create(doc, r.sumEnd) : null;
627
+ }
628
+ const hr = ranges.find((r) => inside(r, head));
629
+ if (hr) head = snap(doc, ranges, hr, dir);
630
+ const ar = ranges.find((r) => inside(r, anchor));
631
+ if (ar) anchor = ar.sumEnd;
632
+ if (!dragging && anchor !== head) {
633
+ const from = Math.min(anchor, head), to = Math.max(anchor, head);
634
+ const r = ranges.find((x) => (from > x.line || from === x.sumEnd) && from < x.from && to >= x.to);
635
+ if (r) {
636
+ const lo = from === r.sumEnd ? snap(doc, ranges, r, 1) : r.line;
637
+ if (anchor < head) anchor = lo; else head = lo;
638
+ }
639
+ }
640
+ if (anchor === sel.anchor && head === sel.head) return null;
641
+ return TextSelection.create(doc, anchor, head);
642
+ }
643
+
644
+ // ---- ordered numbering ------------------------------------------------------
645
+
646
+ const ALPHA = (n) => { let s = ""; while (n > 0) { n--; s = String.fromCharCode(97 + (n % 26)) + s; n = Math.floor(n / 26); } return s; };
647
+ const ROMAN = (n) => [[1000, "m"], [900, "cm"], [500, "d"], [400, "cd"], [100, "c"], [90, "xc"], [50, "l"], [40, "xl"], [10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"]].reduce((s, [v, r]) => { while (n >= v) { s += r; n -= v; } return s; }, "");
648
+
649
+ // Marker text for every ordered item, by entry index. A run restarts numbering;
650
+ // within a run each indent level counts its own ordered items, a deeper item
651
+ // never interrupts a shallower count, and any non-ordered item at a level
652
+ // restarts that level.
653
+ export function orderedMarkers(ents) {
654
+ const out = new Map();
655
+ let counts = [];
656
+ ents.forEach((e, i) => {
657
+ if (!isList(e.node)) { counts = []; return; }
658
+ if (i > 0 && !sameRun(ents[i - 1], e)) counts = [];
659
+ const d = e.node.attrs.indent;
660
+ counts.length = d + 1;
661
+ if (e.node.attrs.kind !== "ordered") { counts[d] = 0; return; }
662
+ counts[d] = (counts[d] || 0) + 1;
663
+ const n = counts[d];
664
+ out.set(i, `${d % 3 === 0 ? n : d % 3 === 1 ? ALPHA(n) : ROMAN(n)}.`);
665
+ });
666
+ return out;
667
+ }
668
+
669
+ // ---- the collapse plugin ----------------------------------------------------
670
+
671
+ export const collapseKey = new PluginKey("page-collapse");
672
+
673
+ const STRUCTURAL = ["id", "parent", "indent", "kind", "open"];
674
+
675
+ // True when no block was added, removed or retyped and no attr the outline
676
+ // reads changed: typing, marks, checked, pos. Unchanged blocks are the same
677
+ // objects in both documents, so this is a pointer walk plus a few attr reads.
678
+ export function sameStructure(a, b) {
679
+ if (a.childCount !== b.childCount) return false;
680
+ for (let i = 0; i < a.childCount; i++) {
681
+ const x = a.child(i), y = b.child(i);
682
+ if (x === y) continue;
683
+ if (x.type !== y.type) return false;
684
+ for (const k of STRUCTURAL) if (x.attrs[k] !== y.attrs[k]) return false;
685
+ }
686
+ return true;
687
+ }
688
+
689
+ function collapseState(doc) {
690
+ const ents = outline(doc);
691
+ return { decos: decorationsFor(doc, ents), ranges: hiddenRanges(ents) };
692
+ }
693
+
694
+ export function decorationsFor(doc, ents = outline(doc)) {
695
+ const { Decoration, DecorationSet } = pmView;
696
+ const markers = orderedMarkers(ents);
697
+ const decos = [];
698
+ for (const e of ents) {
699
+ const attrs = {};
700
+ if (e.hidden) { attrs.class = "page-hidden"; attrs["aria-hidden"] = "true"; }
701
+ if (e.depth) attrs.style = `--page-depth:${e.depth}`;
702
+ if (markers.has(e.i)) attrs["data-marker"] = markers.get(e.i);
703
+ if (isToggle(e.node)) attrs["aria-expanded"] = e.node.attrs.open ? "true" : "false";
704
+ if (Object.keys(attrs).length) decos.push(Decoration.node(e.at, e.end, attrs));
705
+ if (isToggle(e.node) && !e.hidden) {
706
+ decos.push(Decoration.widget(e.at + 1, () => disclosure(e.node), { side: -1, ignoreSelection: true, key: `t:${e.node.attrs.id}:${e.node.attrs.open}` }));
707
+ }
708
+ }
709
+ return DecorationSet.create(doc, decos);
710
+ }
711
+
712
+ function disclosure(node) {
713
+ const b = document.createElement("button");
714
+ b.type = "button";
715
+ b.className = "page-toggle-button";
716
+ b.contentEditable = "false";
717
+ b.setAttribute("aria-label", node.attrs.open ? "Collapse" : "Expand");
718
+ b.setAttribute("data-toggle", node.attrs.id || "");
719
+ return b;
720
+ }
721
+
722
+ export function collapsePlugin() {
723
+ let dragging = false;
724
+ return new Plugin({
725
+ key: collapseKey,
726
+ state: {
727
+ init: (_, s) => collapseState(s.doc),
728
+ apply(tr, value, old, s) {
729
+ if (!tr.docChanged) return value;
730
+ if (!sameStructure(old.doc, s.doc)) return collapseState(s.doc);
731
+ const m = tr.mapping;
732
+ return {
733
+ decos: value.decos.map(m, s.doc),
734
+ ranges: value.ranges.map((r) => ({ line: m.map(r.line, -1), sumEnd: m.map(r.sumEnd), from: m.map(r.from), to: m.map(r.to, -1) })),
735
+ };
736
+ },
737
+ },
738
+ appendTransaction(trs, old, next) {
739
+ if (!trs.some((tr) => tr.docChanged || tr.selectionSet)) return null;
740
+ // A caret hidden by a collapse goes back to the summary; one the user
741
+ // moved into a hidden body keeps going the way it was moving.
742
+ const moved = trs.some((tr) => tr.selectionSet);
743
+ const dir = moved && next.selection.head >= old.selection.head ? 1 : -1;
744
+ const sel = guardSelection(next.doc, next.selection, collapseKey.getState(next).ranges, dir, dragging);
745
+ return sel ? next.tr.setSelection(sel) : null;
746
+ },
747
+ props: {
748
+ decorations: (s) => collapseKey.getState(s).decos,
749
+ handleDOMEvents: {
750
+ mousedown: (view, event) => {
751
+ const btn = event.target.closest?.(".page-toggle-button");
752
+ if (btn) {
753
+ event.preventDefault();
754
+ const ents = outline(view.state.doc);
755
+ const ti = ents.findIndex((e) => e.node.attrs.id === btn.dataset.toggle);
756
+ if (ti >= 0) view.dispatch(setOpen(view.state, ti, !ents[ti].node.attrs.open, ents));
757
+ return true;
758
+ }
759
+ dragging = true;
760
+ const win = view.dom.ownerDocument.defaultView;
761
+ const up = () => {
762
+ dragging = false;
763
+ win.removeEventListener("mouseup", up, true);
764
+ const sel = guardSelection(view.state.doc, view.state.selection, collapseKey.getState(view.state).ranges, 1, false);
765
+ if (sel) view.dispatch(view.state.tr.setSelection(sel));
766
+ };
767
+ win.addEventListener("mouseup", up, true);
768
+ return false;
769
+ },
770
+ },
771
+ handleKeyDown(view, event) {
772
+ const dir = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
773
+ if (!dir || event.altKey || event.metaKey || event.ctrlKey) return false;
774
+ const { head } = view.state.selection;
775
+ const vertical = event.key === "ArrowDown" || event.key === "ArrowUp";
776
+ const edge = vertical ? (dir > 0 ? "down" : "up") : (dir > 0 ? "forward" : "backward");
777
+ if (!view.endOfTextblock(edge)) return false;
778
+ const ents = outline(view.state.doc);
779
+ const { ranges } = collapseKey.getState(view.state);
780
+ const $h = view.state.doc.resolve(head);
781
+ const i = $h.index(0);
782
+ let to = null;
783
+ if (dir > 0) {
784
+ const r = ranges.find((x) => x.from === ents[i].end);
785
+ if (r) to = snap(view.state.doc, ranges, r, 1);
786
+ } else {
787
+ const ti = collapsedBefore(ents, i);
788
+ // Extending backward takes the closed toggle whole: a head at the
789
+ // summary end would be read as A1 (skip the toggle) and never move.
790
+ if (ti >= 0) to = event.shiftKey ? ents[ti].at + 1 : ents[ti].end - 1;
791
+ }
792
+ if (to === null || to === head) return false;
793
+ const anchor = event.shiftKey ? view.state.selection.anchor : to;
794
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, anchor, to)).scrollIntoView());
795
+ return true;
796
+ },
797
+ },
798
+ });
799
+ }
800
+
801
+ // ---- paste ------------------------------------------------------------------
802
+
803
+ const LIST_LINE = /^([ \t]*)(?:([-*+•])|(\d+)[.)])[ \t]+(?:\[([ xX])\][ \t]+)?(.*)$/;
804
+
805
+ // Plain text whose lines are list lines becomes flat list items. Indent levels
806
+ // come from a stack of leading widths (a tab counts 4), so 2-space, 4-space and
807
+ // tab markdown all work. Text with no list line returns null: ProseMirror's
808
+ // default plain-text handling then applies.
809
+ export function parseListText(text) {
810
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
811
+ if (!lines.some((l) => LIST_LINE.test(l))) return null;
812
+ const widths = [];
813
+ const nodes = [];
814
+ for (const line of lines) {
815
+ if (!line.trim()) continue;
816
+ const m = LIST_LINE.exec(line);
817
+ if (!m) { widths.length = 0; nodes.push(N.paragraph.create(null, line.trim() ? schema.text(line.trim()) : null)); continue; }
818
+ const w = m[1].replace(/\t/g, " ").length;
819
+ while (widths.length && widths[widths.length - 1] > w) widths.pop();
820
+ if (!widths.length || widths[widths.length - 1] < w) widths.push(w);
821
+ const indent = Math.min(widths.length - 1, nodes.length && isList(nodes[nodes.length - 1]) ? nodes[nodes.length - 1].attrs.indent + 1 : 0);
822
+ const kind = m[4] !== undefined ? "check" : m[3] !== undefined ? "ordered" : "bullet";
823
+ nodes.push(N.list_item.create({ kind, indent, checked: m[4] === "x" || m[4] === "X" }, m[5] ? schema.text(m[5]) : null));
824
+ }
825
+ return new model.Slice(model.Fragment.from(nodes), 0, 0);
826
+ }
827
+
828
+ const LIST_TAGS = new Set(["UL", "OL"]);
829
+ const BLOCK_TAGS = new Set(["P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "PRE", "BLOCKQUOTE", "UL", "OL", "LI", "DETAILS", "SUMMARY", "TABLE", "HR", "FIGURE"]);
830
+
831
+ function checkState(li) {
832
+ const box = li.querySelector(":scope > input[type=checkbox], :scope > p > input[type=checkbox], :scope > label > input[type=checkbox]");
833
+ if (box) return { check: true, checked: box.checked || box.hasAttribute("checked") };
834
+ const role = li.getAttribute("role") === "checkbox" || li.hasAttribute("aria-checked");
835
+ if (role) return { check: true, checked: li.getAttribute("aria-checked") === "true" };
836
+ if (li.hasAttribute("data-checked")) return { check: true, checked: li.getAttribute("data-checked") === "true" };
837
+ const cls = li.className || "";
838
+ if (/\b(checked|task-list-item|to-do)\b/.test(cls) || li.closest?.("ul.to-do-list, ul.contains-task-list")) {
839
+ return { check: true, checked: /\bchecked\b/.test(cls) || !!li.querySelector(".checkbox-on") };
840
+ }
841
+ return { check: false, checked: false };
842
+ }
843
+
844
+ function listKind(li) {
845
+ const style = (li.getAttribute("style") || "") + ";" + (li.parentElement?.getAttribute("style") || "");
846
+ const t = /list-style-type\s*:\s*([a-z-]+)/i.exec(li.getAttribute("style") || "")?.[1];
847
+ if (t) return /decimal|alpha|roman|latin|greek/.test(t) ? "ordered" : "bullet";
848
+ void style;
849
+ return li.parentElement?.tagName === "OL" ? "ordered" : "bullet";
850
+ }
851
+
852
+ // Rewrites pasted HTML so every list item, at any nesting, is one flat
853
+ // <div data-page-li> carrying kind, indent and checked, and every <details> is
854
+ // a <div data-page-toggle> followed by its body, each top-level body block
855
+ // marked with data-page-parent naming a temporary toggle key. Indent comes from
856
+ // aria-level when present (Google Docs), otherwise from the number of list
857
+ // ancestors (Notion, markdown renderers, GitHub), and is made relative to the
858
+ // shallowest item pasted.
859
+ export function flattenPastedHTML(html, doc = globalThis.document) {
860
+ const root = doc.createElement("div");
861
+ root.innerHTML = html;
862
+ for (const b of root.querySelectorAll('b[style*="font-weight:normal"], b[style*="font-weight: normal"]')) b.replaceWith(...b.childNodes);
863
+ let key = 0;
864
+ const out = doc.createElement("div");
865
+
866
+ const listDepth = (li) => {
867
+ const aria = parseInt(li.getAttribute("aria-level"), 10);
868
+ if (aria > 0) return aria - 1;
869
+ let d = -1;
870
+ for (let el = li.parentElement; el && el !== root; el = el.parentElement) if (LIST_TAGS.has(el.tagName)) d++;
871
+ return Math.max(0, d);
872
+ };
873
+
874
+ const items = [];
875
+ function emitLi(li, parentKey, target) {
876
+ const div = doc.createElement("div");
877
+ const { check, checked } = checkState(li);
878
+ div.setAttribute("data-page-li", "");
879
+ div.setAttribute("data-kind", check ? "check" : listKind(li));
880
+ div.setAttribute("data-checked", checked ? "true" : "false");
881
+ div.dataset.depth = String(listDepth(li));
882
+ if (parentKey) div.setAttribute("data-page-parent", parentKey);
883
+ let first = true;
884
+ const rest = [];
885
+ for (const child of [...li.childNodes]) {
886
+ if (child.nodeType === 1 && LIST_TAGS.has(child.tagName)) { rest.push(child); continue; }
887
+ if (child.nodeType === 1 && child.tagName === "INPUT") continue;
888
+ if (child.nodeType === 1 && BLOCK_TAGS.has(child.tagName)) {
889
+ if (!first) div.appendChild(doc.createElement("br"));
890
+ for (const n of [...child.childNodes]) { if (!(n.nodeType === 1 && n.tagName === "INPUT")) div.appendChild(n); }
891
+ first = false;
892
+ continue;
893
+ }
894
+ div.appendChild(child);
895
+ if (child.nodeType !== 3 || child.textContent.trim()) first = false;
896
+ }
897
+ target.appendChild(div);
898
+ items.push(div);
899
+ for (const list of rest) walk(list, parentKey, target);
900
+ }
901
+
902
+ // Text and inline elements between blocks gather into one paragraph per
903
+ // line; a <br> between them starts the next line.
904
+ function walk(node, parentKey, target) {
905
+ let line = null;
906
+ const inline = (n) => {
907
+ if (!line) {
908
+ line = doc.createElement("p");
909
+ if (parentKey) line.setAttribute("data-page-parent", parentKey);
910
+ target.appendChild(line);
911
+ }
912
+ line.appendChild(n);
913
+ };
914
+ for (const child of [...node.childNodes]) {
915
+ if (child.nodeType === 1 && (child.tagName === "META" || child.tagName === "STYLE")) continue;
916
+ if (child.nodeType === 1 && child.tagName === "BR") { line = null; continue; }
917
+ if (child.nodeType !== 1) {
918
+ if (child.nodeType === 3 && (line || child.textContent.trim())) inline(child);
919
+ continue;
920
+ }
921
+ if (!BLOCK_TAGS.has(child.tagName) && child.tagName !== "ASIDE") { inline(child); continue; }
922
+ line = null;
923
+ if (LIST_TAGS.has(child.tagName)) { walk(child, parentKey, target); continue; }
924
+ if (child.tagName === "LI") { emitLi(child, parentKey, target); continue; }
925
+ if (child.tagName === "DETAILS") {
926
+ const k = `paste-${++key}`;
927
+ const summary = child.querySelector(":scope > summary");
928
+ const t = doc.createElement("div");
929
+ t.setAttribute("data-page-toggle", k);
930
+ if (child.hasAttribute("open")) t.setAttribute("data-open", "true");
931
+ if (parentKey) t.setAttribute("data-page-parent", parentKey);
932
+ if (summary) t.append(...summary.childNodes);
933
+ target.appendChild(t);
934
+ for (const c of [...child.childNodes]) if (c !== summary) { const box = doc.createElement("div"); box.appendChild(c); walk(box, k, target); }
935
+ continue;
936
+ }
937
+ if (child.tagName === "DIV" && !child.hasAttribute("data-page-li") && [...child.children].some((c) => BLOCK_TAGS.has(c.tagName))) { walk(child, parentKey, target); continue; }
938
+ if (parentKey) child.setAttribute("data-page-parent", parentKey);
939
+ target.appendChild(child);
940
+ }
941
+ }
942
+ walk(root, null, out);
943
+ const min = items.length ? Math.min(...items.map((d) => +d.dataset.depth)) : 0;
944
+ let prev = null;
945
+ for (const d of items) {
946
+ const want = +d.dataset.depth - min;
947
+ const adj = prev && d.previousElementSibling === prev ? +prev.getAttribute("data-indent") + 1 : 0;
948
+ d.setAttribute("data-indent", String(Math.min(want, adj)));
949
+ d.removeAttribute("data-depth");
950
+ prev = d;
951
+ }
952
+ const splitImages = (node) => {
953
+ if (node.nodeType !== 1 || node.tagName === "IMG") return [node.cloneNode(true)];
954
+ const parts = [];
955
+ let part = node.cloneNode(false);
956
+ for (const child of node.childNodes) for (const piece of splitImages(child)) {
957
+ if (piece.nodeType === 1 && piece.tagName === "IMG") {
958
+ if (part.hasChildNodes()) parts.push(part);
959
+ parts.push(piece);
960
+ part = node.cloneNode(false);
961
+ } else part.appendChild(piece);
962
+ }
963
+ if (part.hasChildNodes() || !parts.length) parts.push(part);
964
+ return parts;
965
+ };
966
+ for (const child of [...out.children]) {
967
+ if (!child.matches("p, h1, h2, h3, h4, h5, h6, aside, div[data-page-li], div.page-li") || !child.querySelector("img")) continue;
968
+ const pieces = splitImages(child);
969
+ const parent = child.getAttribute("data-page-parent");
970
+ if (parent) for (const piece of pieces) piece.setAttribute("data-page-parent", parent);
971
+ child.replaceWith(...pieces);
972
+ }
973
+ return out.innerHTML;
974
+ }
975
+
976
+ const numberAttr = (value, min = 0) => value !== null && value !== "" && Number.isInteger(+value) && +value >= min ? +value : null;
977
+ const parentAttr = (dom) => dom.getAttribute("data-page-parent") || null;
978
+
979
+ // The clipboard parser: the schema has toDOM only, so paste gets its own rules.
980
+ export const clipboardParser = new model.DOMParser(schema, [
981
+ { tag: "div[data-page-li]", node: "list_item", getAttrs: (d) => ({ kind: d.getAttribute("data-kind") || "bullet", indent: +d.getAttribute("data-indent") || 0, checked: d.getAttribute("data-checked") === "true", parent: parentAttr(d) }) },
982
+ { tag: "div.page-li", node: "list_item", getAttrs: (d) => ({ kind: d.getAttribute("data-kind") || "bullet", indent: +d.getAttribute("data-indent") || 0, checked: d.getAttribute("data-checked") === "true", parent: parentAttr(d) }) },
983
+ { tag: "div[data-page-toggle]", node: "toggle", getAttrs: (d) => ({ id: d.getAttribute("data-page-toggle"), open: d.getAttribute("data-open") === "true", parent: parentAttr(d) }) },
984
+ { tag: "div.page-toggle", node: "toggle", getAttrs: (d) => ({ id: d.getAttribute("data-id"), open: d.getAttribute("data-open") === "true", parent: null }) },
985
+ { tag: "h1", node: "heading", getAttrs: (d) => ({ level: 1, parent: parentAttr(d) }) },
986
+ { tag: "h2", node: "heading", getAttrs: (d) => ({ level: 2, parent: parentAttr(d) }) },
987
+ { tag: "h3", node: "heading", getAttrs: (d) => ({ level: 3, parent: parentAttr(d) }) },
988
+ { tag: "h4", node: "heading", getAttrs: (d) => ({ level: 3, parent: parentAttr(d) }) },
989
+ { tag: "pre", node: "code_block", preserveWhitespace: "full", getAttrs: (d) => ({ lang: d.getAttribute("data-lang") || "", parent: parentAttr(d) }) },
990
+ { tag: "hr", node: "divider", getAttrs: (d) => ({ parent: parentAttr(d) }) },
991
+ { tag: "aside.page-callout", node: "callout", getAttrs: (d) => ({ parent: parentAttr(d) }) },
992
+ { tag: 'figure:not([data-type]), figure[data-type="image"]', node: "image", getAttrs: (d) => {
993
+ const img = d.querySelector("img"), src = safeSrc(img?.getAttribute("src"));
994
+ return src ? { src, alt: img.getAttribute("alt") || "", caption: d.querySelector("figcaption")?.textContent || "", width: numberAttr(d.getAttribute("data-width") ?? img.getAttribute("width"), 1), align: alignOf(d.getAttribute("data-align")), parent: parentAttr(d) } : false;
995
+ } },
996
+ { tag: "img[src]", node: "image", getAttrs: (d) => {
997
+ const src = safeSrc(d.getAttribute("src"));
998
+ return src ? { src, alt: d.getAttribute("alt") || "", width: numberAttr(d.getAttribute("width"), 1), parent: parentAttr(d) } : false;
999
+ } },
1000
+ { tag: 'div.page-file, div[data-type="file"]', node: "file", getAttrs: (d) => {
1001
+ const src = safeHref(d.getAttribute("data-src"));
1002
+ return src ? { src, name: d.getAttribute("data-name") || "file", size: numberAttr(d.getAttribute("data-size")), mime: d.getAttribute("data-mime") || null, parent: parentAttr(d) } : false;
1003
+ } },
1004
+ { tag: "p", node: "paragraph", getAttrs: (d) => ({ parent: parentAttr(d) }) },
1005
+ { tag: "br", node: "hard_break" },
1006
+ { tag: "strong", mark: "strong" },
1007
+ { tag: "b", mark: "strong", getAttrs: (d) => (/font-weight\s*:\s*(normal|[1-5]00)\b/.test(d.getAttribute("style") || "") ? false : null) },
1008
+ { tag: "span", mark: "strong", getAttrs: (d) => (/font-weight\s*:\s*(bold|[6-9]00)\b/.test(d.getAttribute("style") || "") ? null : false) },
1009
+ { tag: "em", mark: "em" },
1010
+ { tag: "i", mark: "em" },
1011
+ { tag: "span", mark: "em", getAttrs: (d) => (/font-style\s*:\s*italic/.test(d.getAttribute("style") || "") ? null : false) },
1012
+ { tag: "s", mark: "strike" },
1013
+ { tag: "del", mark: "strike" },
1014
+ { tag: "code", mark: "code" },
1015
+ { tag: "a[href]", mark: "link", getAttrs: (d) => { const href = safeHref(d.getAttribute("href")); return href ? { href } : false; } },
1016
+ ]);
1017
+
1018
+ // Every pasted block arrives with no id and no pos (identityPlugin mints and
1019
+ // places them). Toggles alone get a fresh id here, because their bodies must
1020
+ // name it; each pasted body is rewired to its toggle's new id. A block whose
1021
+ // parent is not in the slice takes the parent of the block being pasted into,
1022
+ // and pasted list items shift by that item's indent.
1023
+ // Pasted into a paragraph or heading, a slice that starts or ends with a list
1024
+ // item or toggle is closed on that side, so the items arrive whole instead of
1025
+ // their first line merging into the paragraph.
1026
+ export function remapPasted(slice, st) {
1027
+ const $from = st.selection.$from;
1028
+ const selected = st.selection instanceof BlockSelection;
1029
+ const host = selected ? st.doc.child(st.selection.span().lo) : $from.depth >= 1 ? $from.node(1) : null;
1030
+ const destParent = host ? parentOf(host) : null;
1031
+ const base = host && isList(host) ? host.attrs.indent : 0;
1032
+ const ids = new Map();
1033
+ slice.content.forEach((n) => { if (isToggle(n) && n.attrs.id) ids.set(n.attrs.id, newId()); });
1034
+ const nodes = [];
1035
+ slice.content.forEach((n) => {
1036
+ if (!n.type.spec.group?.includes("block")) { nodes.push(n); return; }
1037
+ const p = parentOf(n);
1038
+ const attrs = { ...n.attrs, pos: null, id: isToggle(n) ? (ids.get(n.attrs.id) ?? null) : null, parent: p !== null && ids.has(p) ? ids.get(p) : destParent };
1039
+ if (isList(n)) attrs.indent = n.attrs.indent + base;
1040
+ nodes.push(n.type.create(attrs, n.content, n.marks));
1041
+ });
1042
+ const intoText = !host || !(isList(host) || isToggle(host));
1043
+ const structural = (n) => n && (isList(n) || isToggle(n));
1044
+ const openStart = intoText && structural(nodes[0]) ? 0 : slice.openStart;
1045
+ const last = nodes[nodes.length - 1];
1046
+ const fresh = new Set(ids.values());
1047
+ const openEnd = intoText && (structural(last) || (last && fresh.has(parentOf(last)))) ? 0 : slice.openEnd;
1048
+ return new model.Slice(model.Fragment.from(nodes), selected ? 0 : openStart, selected ? 0 : openEnd);
1049
+ }
1050
+
1051
+ // The clipboard serializer: the schema's toDOM, plus each copied block's
1052
+ // owner (data-page-parent) and each toggle's id (data-page-toggle), so a copied
1053
+ // toggle pastes with its body still inside it.
1054
+ class ClipboardSerializer extends model.DOMSerializer {
1055
+ serializeFragment(fragment, options, target) {
1056
+ const out = super.serializeFragment(fragment, options, target);
1057
+ if (!fragment.firstChild?.isBlock) return out;
1058
+ const doms = [...out.childNodes];
1059
+ fragment.forEach((n, _, i) => {
1060
+ const dom = doms[i];
1061
+ if (!dom || dom.nodeType !== 1) return;
1062
+ if (parentOf(n) !== null) dom.setAttribute("data-page-parent", parentOf(n));
1063
+ if (isToggle(n) && n.attrs.id) dom.setAttribute("data-page-toggle", n.attrs.id);
1064
+ if (n.type === N.image && n.attrs.width) dom.setAttribute("data-width", String(n.attrs.width));
1065
+ });
1066
+ return out;
1067
+ }
1068
+ }
1069
+ const base = model.DOMSerializer.fromSchema(schema);
1070
+ export const clipboardSerializer = new ClipboardSerializer(base.nodes, base.marks);
1071
+
1072
+ // Several blocks pasted into a closed toggle's summary join its body, so the
1073
+ // toggle opens first instead of hiding what was just pasted.
1074
+ function pasteIntoClosedSummary(view, _event, slice) {
1075
+ const { $from } = view.state.selection;
1076
+ const host = $from.depth >= 1 ? $from.node(1) : null;
1077
+ if (!host || !isToggle(host) || host.attrs.open || slice.content.childCount < 2) return false;
1078
+ const tr = view.state.tr.setNodeMarkup($from.before(1), null, { ...host.attrs, open: true }).replaceSelection(slice);
1079
+ view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste"));
1080
+ return true;
1081
+ }
1082
+
1083
+ export function pastePlugin() {
1084
+ return new Plugin({
1085
+ props: {
1086
+ handlePaste: pasteIntoClosedSummary,
1087
+ clipboardSerializer,
1088
+ transformPastedHTML: (html) => flattenPastedHTML(html),
1089
+ clipboardParser,
1090
+ clipboardTextParser: (text, _$ctx, plain) => (plain ? null : parseListText(text)),
1091
+ transformPasted: (slice, view) => remapPasted(slice, view.state),
1092
+ },
1093
+ });
1094
+ }
1095
+
1096
+ // ---- keymap -----------------------------------------------------------------
1097
+
1098
+ const backspace = commands.chainCommands(backspaceListItem, backspaceToggle, backspaceBodyBlock, backspaceAfterCollapsed);
1099
+
1100
+ // Modified Backspace and Delete get the same guards as the plain keys, so none
1101
+ // of them can reach baseKeymap's joins into a hidden body. Where no guard
1102
+ // applies they fall through to the base keymap or the browser as before.
1103
+ export const blockKeys = {
1104
+ Enter: commands.chainCommands(enterListItem, enterToggle, exitToggleOnEmpty, enterBodyBlock),
1105
+ Backspace: backspace,
1106
+ "Shift-Backspace": backspace,
1107
+ "Mod-Backspace": backspace,
1108
+ "Alt-Backspace": backspace,
1109
+ "Ctrl-h": backspace,
1110
+ Delete: deleteAtCollapsedEnd,
1111
+ "Shift-Delete": deleteAtCollapsedEnd,
1112
+ "Mod-Delete": deleteAtCollapsedEnd,
1113
+ "Alt-Delete": deleteAtCollapsedEnd,
1114
+ "Ctrl-d": deleteAtCollapsedEnd,
1115
+ "Ctrl-Alt-Backspace": deleteAtCollapsedEnd,
1116
+ Tab: indentBlocks,
1117
+ "Shift-Tab": outdentBlocks,
1118
+ "Mod-Shift-ArrowUp": moveBlock(-1),
1119
+ "Mod-Shift-ArrowDown": moveBlock(1),
1120
+ "Mod-Enter": toggleOpenAtSelection,
1121
+ };