@panphora/clayjs 1.13.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69385 -40992
  3. package/dist/server/checkpoint.cjs +1 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/core.cjs +72210 -0
  6. package/dist/server/rooms.cjs +1 -0
  7. package/dist/server/rooms.mjs +66 -0
  8. package/docs/guide.md +127 -99
  9. package/entries/clay.js +22 -0
  10. package/entries/sap.js +5 -5
  11. package/package.json +16 -2
  12. package/packed-contract.json +18 -3
  13. package/src/core/boot-phase.js +6 -0
  14. package/src/core/conflict-download.js +12 -3
  15. package/src/core/host-meta.js +75 -0
  16. package/src/core/persist.js +11 -0
  17. package/src/core/save-core.js +266 -31
  18. package/src/core/save.js +154 -36
  19. package/src/core/snapshot.js +51 -13
  20. package/src/core/style-comparisons.js +37 -0
  21. package/src/dom/insert-style-tag.js +11 -12
  22. package/src/lib/derived.js +116 -0
  23. package/src/lib/effect.js +130 -0
  24. package/src/lib/mutation.js +3 -1
  25. package/src/lib/style-adoption.js +9 -0
  26. package/src/lib/style.js +47 -0
  27. package/src/live/fast-lane.js +347 -60
  28. package/src/live/local-lane.js +32 -6
  29. package/src/live/ops.js +77 -0
  30. package/src/live/provenance.js +129 -0
  31. package/src/live/save-history.js +188 -0
  32. package/src/live/server-lane.js +9 -2
  33. package/src/live/store.js +194 -16
  34. package/src/loader-logic.js +16 -4
  35. package/src/loader.js +37 -1
  36. package/src/page/address.js +20 -0
  37. package/src/page/adopt-boot.js +189 -0
  38. package/src/page/adopt.js +240 -0
  39. package/src/page/ai-tool.js +188 -0
  40. package/src/page/announce.js +21 -0
  41. package/src/page/blocks.js +1170 -0
  42. package/src/page/blocksel.js +269 -0
  43. package/src/page/board-drag.js +226 -0
  44. package/src/page/board-panel.js +353 -0
  45. package/src/page/board-style.js +146 -0
  46. package/src/page/board-view.js +447 -0
  47. package/src/page/board.js +41 -0
  48. package/src/page/chart.js +220 -0
  49. package/src/page/checklist.js +143 -0
  50. package/src/page/chrome.js +587 -0
  51. package/src/page/csv-import.js +169 -0
  52. package/src/page/drag.js +202 -0
  53. package/src/page/drop.js +112 -0
  54. package/src/page/editor.js +386 -0
  55. package/src/page/highlight.js +268 -0
  56. package/src/page/inline.js +60 -1
  57. package/src/page/items.js +159 -0
  58. package/src/page/link.js +247 -0
  59. package/src/page/markdown.js +71 -0
  60. package/src/page/media.js +570 -0
  61. package/src/page/merge.js +130 -0
  62. package/src/page/model.js +71 -9
  63. package/src/page/placeholder.js +23 -0
  64. package/src/page/projection.js +647 -0
  65. package/src/page/recover.js +193 -0
  66. package/src/page/rich/cell.js +16 -0
  67. package/src/page/rich/chart.js +104 -0
  68. package/src/page/rich/csv.js +118 -0
  69. package/src/page/rich/page-tool.js +86 -0
  70. package/src/page/rich/tool-runtime.js +195 -0
  71. package/src/page/save-projection.js +53 -0
  72. package/src/page/save-states.js +85 -0
  73. package/src/page/schema.js +12 -5
  74. package/src/page/table-kinds.js +20 -0
  75. package/src/page/table-paste.js +57 -0
  76. package/src/page/table-select.js +244 -0
  77. package/src/page/table-style.js +25 -0
  78. package/src/page/table.js +650 -0
  79. package/src/page/themes/content.css +46 -0
  80. package/src/page/themes/light-1.css +269 -0
  81. package/src/page/themes/light-2.css +211 -0
  82. package/src/page/themes/light-3.css +130 -0
  83. package/src/page/themes/light-4.css +299 -0
  84. package/src/page/tool-data.js +25 -0
  85. package/src/page/tool-view.js +68 -0
  86. package/src/page/tools.js +133 -0
  87. package/src/page/view.js +136 -0
  88. package/src/page/ybind/author.js +25 -0
  89. package/src/page/ybind/binding.js +92 -0
  90. package/src/page/ybind/board-commands.js +170 -0
  91. package/src/page/ybind/board.js +89 -0
  92. package/src/page/ybind/bridge.js +382 -0
  93. package/src/page/ybind/conversion.js +26 -0
  94. package/src/page/ybind/core.js +7 -0
  95. package/src/page/ybind/decode.js +208 -0
  96. package/src/page/ybind/doc.js +58 -0
  97. package/src/page/ybind/external.js +190 -0
  98. package/src/page/ybind/group.js +124 -0
  99. package/src/page/ybind/index.js +12 -0
  100. package/src/page/ybind/layout.js +27 -0
  101. package/src/page/ybind/marks.js +18 -0
  102. package/src/page/ybind/migrate.js +152 -0
  103. package/src/page/ybind/moves.js +288 -0
  104. package/src/page/ybind/origins.js +257 -0
  105. package/src/page/ybind/parts.js +44 -0
  106. package/src/page/ybind/projection-map.js +92 -0
  107. package/src/page/ybind/provider.js +285 -0
  108. package/src/page/ybind/recovery.js +411 -0
  109. package/src/page/ybind/seed.js +27 -0
  110. package/src/page/ybind/table.js +166 -0
  111. package/src/page/ybind/tokens.js +93 -0
  112. package/src/page/ybind/walk.js +333 -0
  113. package/src/page/ybind/writer.js +307 -0
  114. package/src/plugins/derive.js +12 -0
  115. package/src/plugins/indicator.js +16 -6
  116. package/src/plugins/live.js +742 -65
  117. package/src/plugins/page.js +547 -0
  118. package/src/plugins/undo.js +62 -4
  119. package/src/server/checkpoint/index.js +218 -0
  120. package/src/server/rooms/index.js +59 -0
  121. package/src/server/rooms/live-data-ops.js +369 -0
  122. package/src/server/rooms/live-data-rooms.js +965 -0
  123. package/src/server/rooms/live-data-tags.js +49 -0
  124. package/src/server/rooms/live-page-history.js +41 -0
  125. package/src/server/rooms/live-provenance.js +142 -0
  126. package/src/server/rooms/page-admission.js +79 -0
  127. package/src/server/rooms/page-conversion.js +62 -0
  128. package/src/server/rooms/page-rebase.js +108 -0
  129. package/src/server/rooms/page-state.js +91 -0
  130. package/src/server/rooms/page-write-admission.js +63 -0
  131. package/src/server/rooms/page-write-guard.js +23 -0
  132. package/src/server/rooms/recent.js +18 -0
  133. package/src/server/rooms/spec-wire.js +17 -0
  134. package/src/standalone.js +22 -0
  135. package/src/sync/conflict-revert.js +2 -0
  136. package/src/sync/conflicts.js +2 -2
  137. package/src/sync/live-sync.js +36 -8
  138. package/src/sync/stream.js +62 -16
  139. package/src/ui/index.js +4 -0
  140. package/src/ui/toast.js +21 -5
  141. package/src/vendor/pierre/pierre.vendor.js +3 -0
  142. package/src/vendor/sap-core.vendor.js +9 -0
  143. package/src/vendor/sap-ownership.vendor.js +111 -0
  144. package/src/vendor/yjs.vendor.js +7 -0
  145. package/src/page/binding.js +0 -647
  146. package/src/page/replay.js +0 -578
@@ -0,0 +1,268 @@
1
+ // Syntax colours for code blocks. A code block with a language gets inline
2
+ // decorations from Pierre's highlighter, loaded on first use (src/vendor/pierre/).
3
+ // Only blocks on screen are highlighted, and only after typing pauses. Colours are
4
+ // --page-code-token-* variables, so each page theme sets them.
5
+ import { state, view as pmView } from "../vendor/prosemirror.vendor.js";
6
+ import { schema } from "./schema.js";
7
+ import { asStep } from "./blocksel.js";
8
+
9
+ const { Plugin, PluginKey } = state;
10
+ const { Decoration, DecorationSet } = pmView;
11
+
12
+ export const LANGS = [
13
+ ["", "Plain text"],
14
+ ["javascript", "JavaScript"],
15
+ ["typescript", "TypeScript"],
16
+ ["jsx", "JSX"],
17
+ ["tsx", "TSX"],
18
+ ["json", "JSON"],
19
+ ["html", "HTML"],
20
+ ["css", "CSS"],
21
+ ["python", "Python"],
22
+ ["shellscript", "Shell"],
23
+ ["sql", "SQL"],
24
+ ["go", "Go"],
25
+ ["rust", "Rust"],
26
+ ["java", "Java"],
27
+ ["c", "C"],
28
+ ["cpp", "C++"],
29
+ ["csharp", "C#"],
30
+ ["ruby", "Ruby"],
31
+ ["php", "PHP"],
32
+ ["yaml", "YAML"],
33
+ ["markdown", "Markdown"],
34
+ ["diff", "Diff"],
35
+ ["swift", "Swift"],
36
+ ["kotlin", "Kotlin"],
37
+ ["xml", "XML"],
38
+ ];
39
+ const IDS = new Set(LANGS.map(([id]) => id));
40
+ const ALIASES = { js: "javascript", mjs: "javascript", ts: "typescript", py: "python", sh: "shellscript", bash: "shellscript", shell: "shellscript", zsh: "shellscript", rb: "ruby", yml: "yaml", md: "markdown", htm: "html", golang: "go", rs: "rust", "c++": "cpp", cs: "csharp", "c#": "csharp", kt: "kotlin" };
41
+
42
+ export const langOf = (lang) => {
43
+ const k = String(lang ?? "").trim().toLowerCase();
44
+ const id = ALIASES[k] ?? k;
45
+ return IDS.has(id) ? id : "";
46
+ };
47
+
48
+ // Pierre's scopes, onto the page themes' token colours. A scope it does not list
49
+ // falls back to its parent (keyword.control to keyword), then to the foreground.
50
+ const color = (name) => ({ color: name });
51
+ const THEME = {
52
+ name: "page",
53
+ appearance: "light",
54
+ cssVariables: { prefix: "--page-code-token-" },
55
+ style: {
56
+ syntax: {
57
+ comment: color("comment"),
58
+ keyword: color("keyword"),
59
+ operator: color("keyword"),
60
+ preproc: color("keyword"),
61
+ "punctuation.special": color("keyword"),
62
+ string: color("string-expression"),
63
+ "string.escape": color("constant"),
64
+ "string.regex": color("constant"),
65
+ "string.special": color("constant"),
66
+ number: color("constant"),
67
+ boolean: color("constant"),
68
+ constant: color("constant"),
69
+ type: color("constant"),
70
+ property: color("constant"),
71
+ enum: color("constant"),
72
+ variant: color("constant"),
73
+ namespace: color("constant"),
74
+ label: color("constant"),
75
+ function: color("function"),
76
+ constructor: color("function"),
77
+ selector: color("function"),
78
+ attribute: color("function"),
79
+ "variable.parameter": color("parameter"),
80
+ tag: color("string-expression"),
81
+ link_uri: color("link"),
82
+ link_text: color("link"),
83
+ "diff.plus": color("string-expression"),
84
+ "diff.minus": color("keyword"),
85
+ title: { font_weight: 700 },
86
+ emphasis: { font_style: "italic" },
87
+ "emphasis.strong": { font_weight: 700 },
88
+ },
89
+ },
90
+ };
91
+
92
+ let pierre = null;
93
+ export async function tokenize(code, lang) {
94
+ pierre ??= import("../vendor/pierre/pierre.vendor.js");
95
+ const { codeToTokens } = await pierre;
96
+ return codeToTokens(code, { lang, theme: THEME }).tokens;
97
+ }
98
+
99
+ const FOREGROUND = "var(--page-code-token-foreground)";
100
+ const styleOf = (t) => {
101
+ const s = [];
102
+ if (t.color && t.color !== FOREGROUND) s.push(`color: ${t.color}`);
103
+ if (t.fontStyle & 1) s.push("font-style: italic");
104
+ if (t.fontStyle & 2) s.push("font-weight: 600");
105
+ if (t.fontStyle & 4) s.push("text-decoration: underline");
106
+ return s.join("; ");
107
+ };
108
+
109
+ export const highlightKey = new PluginKey("page-highlight");
110
+
111
+
112
+ const CACHE_SIZE = 200;
113
+
114
+ // Each pass rebuilds every code block's colours from a cache of token runs keyed
115
+ // by language and text, so colours follow undo, retyping and deletion without
116
+ // tracking what each edit did to the old decorations. While new text waits for
117
+ // its tokens, the block keeps its old colours, mapped through the edit, so
118
+ // typing never flashes the block uncoloured.
119
+ class Highlighter {
120
+ constructor(view, run, delay) {
121
+ this.view = view;
122
+ this.run = run;
123
+ this.delay = delay;
124
+ this.cache = new Map();
125
+ this.pending = new Set();
126
+ this.visible = new Set();
127
+ this.targets = new Map();
128
+ this.timer = 0;
129
+ const IO = view.dom.ownerDocument.defaultView.IntersectionObserver;
130
+ this.io = IO ? new IO((entries) => {
131
+ for (const e of entries) {
132
+ const id = e.target.dataset.id;
133
+ if (this.targets.get(id) === e.target) this.visible[e.isIntersecting ? "add" : "delete"](id);
134
+ }
135
+ this.schedule(0);
136
+ }, { rootMargin: "200px 0px" }) : null;
137
+ this.schedule(0);
138
+ }
139
+
140
+ update(view, prev) {
141
+ if (!prev || view.state.doc !== prev.doc) this.schedule(this.delay);
142
+ }
143
+
144
+ schedule(ms) {
145
+ clearTimeout(this.timer);
146
+ this.timer = setTimeout(() => this.pass(), ms);
147
+ }
148
+
149
+ pass() {
150
+ const { view } = this;
151
+ if (view.isDestroyed) return;
152
+ const decos = [];
153
+ const seen = new Map();
154
+ const current = highlightKey.getState(view.state);
155
+ view.state.doc.forEach((node, pos) => {
156
+ if (node.type !== schema.nodes.code_block) return;
157
+ const id = node.attrs.id;
158
+ const dom = view.nodeDOM(pos);
159
+ if (dom) seen.set(id, dom);
160
+ const lang = langOf(node.attrs.lang);
161
+ if (!lang) return;
162
+ const text = node.textContent;
163
+ const key = `${lang}\n${text}`;
164
+ const tokens = this.cache.get(key);
165
+ if (tokens) {
166
+ for (const t of tokens) {
167
+ const style = styleOf(t);
168
+ if (style && t.content) decos.push(Decoration.inline(pos + 1 + t.offset, pos + 1 + t.offset + t.content.length, { style }));
169
+ }
170
+ } else {
171
+ decos.push(...current.find(pos, pos + node.nodeSize));
172
+ if ((this.io && !this.visible.has(id)) || this.pending.has(key)) return;
173
+ this.pending.add(key);
174
+ this.run(text, lang).then((lines) => lines.flat(), () => []).then((flat) => {
175
+ this.pending.delete(key);
176
+ this.cache.set(key, flat);
177
+ if (this.cache.size > CACHE_SIZE) this.cache.delete(this.cache.keys().next().value);
178
+ this.schedule(0);
179
+ });
180
+ }
181
+ });
182
+ this.track(seen);
183
+ const sig = (list) => list.map((d) => `${d.from}:${d.to}:${d.type.attrs.style}`).join("|");
184
+ if (sig(decos) === sig(highlightKey.getState(view.state).find())) return;
185
+ view.dispatch(view.state.tr.setMeta(highlightKey, decos).setMeta("addToHistory", false));
186
+ }
187
+
188
+ track(seen) {
189
+ if (!this.io) return;
190
+ for (const [id, dom] of this.targets) {
191
+ if (seen.get(id) === dom) continue;
192
+ this.io.unobserve(dom);
193
+ this.targets.delete(id);
194
+ this.visible.delete(id);
195
+ }
196
+ for (const [id, dom] of seen) {
197
+ if (this.targets.get(id) === dom) continue;
198
+ this.targets.set(id, dom);
199
+ this.io.observe(dom);
200
+ }
201
+ }
202
+
203
+ destroy() {
204
+ clearTimeout(this.timer);
205
+ this.io?.disconnect();
206
+ }
207
+ }
208
+
209
+ export function highlightPlugin({ tokenize: run = tokenize, delay = 300 } = {}) {
210
+ return new Plugin({
211
+ key: highlightKey,
212
+ state: {
213
+ init: () => DecorationSet.empty,
214
+ apply(tr, set) {
215
+ const decos = tr.getMeta(highlightKey);
216
+ return decos ? DecorationSet.create(tr.doc, decos) : set.map(tr.mapping, tr.doc);
217
+ },
218
+ },
219
+ props: { decorations: (st) => highlightKey.getState(st) },
220
+ view: (view) => new Highlighter(view, run, delay),
221
+ });
222
+ }
223
+
224
+ // A code block with a language picker in its top corner. The picker is not part
225
+ // of the content: ProseMirror ignores its events and mutations.
226
+ export class CodeBlockView {
227
+ constructor(node, view, getPos) {
228
+ this.node = node;
229
+ this.view = view;
230
+ this.getPos = getPos;
231
+ const doc = view.dom.ownerDocument;
232
+ this.dom = doc.createElement("pre");
233
+ this.contentDOM = doc.createElement("code");
234
+ this.select = doc.createElement("select");
235
+ this.select.className = "page-code-lang";
236
+ this.select.contentEditable = "false";
237
+ this.select.setAttribute("aria-label", "Code language");
238
+ for (const [id, label] of LANGS) this.select.append(new Option(label, id));
239
+ this.select.addEventListener("change", () => this.setLang(this.select.value));
240
+ this.dom.append(this.select, this.contentDOM);
241
+ this.render(node);
242
+ }
243
+
244
+ render(node) {
245
+ this.node = node;
246
+ this.dom.dataset.id = node.attrs.id ?? "";
247
+ const lang = langOf(node.attrs.lang);
248
+ if (lang) this.dom.dataset.lang = lang;
249
+ else delete this.dom.dataset.lang;
250
+ this.select.value = lang;
251
+ }
252
+
253
+ setLang(lang) {
254
+ const pos = this.getPos();
255
+ if (pos == null || lang === langOf(this.node.attrs.lang)) return;
256
+ this.view.dispatch(asStep(this.view.state.tr.setNodeMarkup(pos, null, { ...this.node.attrs, lang })));
257
+ this.view.focus();
258
+ }
259
+
260
+ update(node) {
261
+ if (node.type !== this.node.type) return false;
262
+ this.render(node);
263
+ return true;
264
+ }
265
+
266
+ stopEvent(event) { return this.select.contains(event.target); }
267
+ ignoreMutation(m) { return m.type !== "selection" && this.select.contains(m.target); }
268
+ }
@@ -1,5 +1,5 @@
1
1
  // A text block's inline content as the live record keeps it: c, its flat text,
2
- // and m, one mark key per UTF-16 unit of c. The replay (replay.js) merges in
2
+ // and m, one mark key per UTF-16 unit of c (stored as runs, below). The replay (replay.js) merges in
3
3
  // this form; the editor gets ProseMirror nodes back from it.
4
4
  //
5
5
  // A mark key lists a unit's marks in schema order, one code each, a mark with
@@ -87,6 +87,65 @@ export function flatten(content) {
87
87
  return { c, m };
88
88
  }
89
89
 
90
+ // The record keeps m as runs instead, [[count, key], ...] in order, so its
91
+ // size follows the formatting rather than the length of the text: neighbouring
92
+ // runs differ, and the counts add up to the length of c. Memory keeps one key
93
+ // per unit, and so do records written before runs; both open.
94
+ export function runsOf(m) {
95
+ const out = [];
96
+ for (const key of m) {
97
+ const last = out[out.length - 1];
98
+ if (last && last[1] === key) last[0]++;
99
+ else out.push([1, key]);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ const isRuns = (m) => Array.isArray(m) && Array.isArray(m[0]);
105
+
106
+ // One key per unit for `length` units, from runs; per-unit keys come back as
107
+ // they are. null for runs that do not cover exactly `length` units: a write
108
+ // that changed c alone keeps none of its marks, as a per-unit list of the
109
+ // wrong length keeps none in the replay.
110
+ export function unitsOf(m, length) {
111
+ if (!isRuns(m)) return m;
112
+ const out = [];
113
+ for (const run of m) {
114
+ if (!Array.isArray(run) || !Number.isInteger(run[0]) || run[0] < 1 || typeof run[1] !== "string" || out.length + run[0] > length) return null;
115
+ for (let i = 0; i < run[0]; i++) out.push(run[1]);
116
+ }
117
+ return out.length === length ? out : null;
118
+ }
119
+
120
+ // A block or a write as the record keeps it: m, and the removed marks of each
121
+ // of its splices, as runs.
122
+ export function packMarks(X) {
123
+ if (!X || typeof X !== "object") return X;
124
+ const out = { ...X };
125
+ if (Array.isArray(X.m) && !isRuns(X.m)) out.m = runsOf(X.m);
126
+ if (X.w && Array.isArray(X.w.s) && X.w.s.some((sp) => Array.isArray(sp) && Array.isArray(sp[4]) && !isRuns(sp[4]))) {
127
+ out.w = { ...X.w, s: X.w.s.map((sp) => (Array.isArray(sp) && Array.isArray(sp[4]) && !isRuns(sp[4]) ? [...sp.slice(0, 4), runsOf(sp[4]), ...sp.slice(5)] : sp)) };
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // The same block or write with one key per unit, as memory and replay.js keep
133
+ // it; X itself when it already is. Runs that do not cover the text come back
134
+ // as an empty list: like a per-unit list of the wrong length, it gives no
135
+ // marks, and the writer's repair rewrites the block.
136
+ export function unpackMarks(X) {
137
+ if (!X || typeof X !== "object") return X;
138
+ const runs = isRuns(X.m) && typeof X.c === "string";
139
+ const spliced = X.w && Array.isArray(X.w.s) && X.w.s.some((sp) => Array.isArray(sp) && isRuns(sp[4]));
140
+ if (!runs && !spliced) return X;
141
+ const out = { ...X };
142
+ if (runs) out.m = unitsOf(X.m, X.c.length) ?? [];
143
+ if (spliced) {
144
+ out.w = { ...X.w, s: X.w.s.map((sp) => (Array.isArray(sp) && isRuns(sp[4]) ? [...sp.slice(0, 4), unitsOf(sp[4], typeof sp[2] === "string" ? sp[2].length : 0) ?? [], ...sp.slice(5)] : sp)) };
145
+ }
146
+ return out;
147
+ }
148
+
90
149
  const isHigh = (u) => u >= 0xd800 && u <= 0xdbff;
91
150
  const textKey = (key) => (key && key[0] !== "@" ? key : "");
92
151
 
@@ -0,0 +1,159 @@
1
+ // What the block menu lists and how a pick changes the page: the block types a
2
+ // textblock can be turned into, the things that can be inserted, how a search
3
+ // ranks them, and the transactions behind them. Pure data and transactions: the
4
+ // menu itself lives in chrome.js.
5
+ import { state } from "../vendor/prosemirror.vendor.js";
6
+ import { schema } from "./schema.js";
7
+ import { isKey, keyBetween, newId } from "./model.js";
8
+ import { outline, subtreeRange } from "./blocks.js";
9
+ import { asStep } from "./blocksel.js";
10
+ import { pickFiles, hostPlace } from "./media.js";
11
+ import { makeTable, insertTableNode } from "./table.js";
12
+ import { pickCsv } from "./csv-import.js";
13
+ import { makeBoard, insertBoardNode } from "./board.js";
14
+ import { chartFromSlash, chartTables } from "./chart.js";
15
+
16
+ const { TextSelection } = state;
17
+ const N = schema.nodes;
18
+
19
+ export const BLOCK_TYPES = [
20
+ { key: "text", label: "Text", description: "Plain writing", icon: "T", type: "paragraph", aliases: ["p", "paragraph", "plain"] },
21
+ { key: "h1", label: "Heading 1", description: "Big section heading", icon: "H1", type: "heading", attrs: { level: 1 }, hint: "#", aliases: ["h1", "title"] },
22
+ { key: "h2", label: "Heading 2", description: "Medium section heading", icon: "H2", type: "heading", attrs: { level: 2 }, hint: "##", aliases: ["h2", "subtitle"] },
23
+ { key: "h3", label: "Heading 3", description: "Small section heading", icon: "H3", type: "heading", attrs: { level: 3 }, hint: "###", aliases: ["h3"] },
24
+ { key: "bullet", label: "Bulleted list", description: "A simple list", icon: "•", type: "list_item", attrs: { kind: "bullet" }, hint: "-", aliases: ["ul", "bullet", "list", "unordered"] },
25
+ { key: "ordered", label: "Numbered list", description: "A list with numbers", icon: "1.", type: "list_item", attrs: { kind: "ordered" }, hint: "1.", aliases: ["ol", "num", "numbered", "ordered"] },
26
+ { key: "check", label: "Checklist", description: "Tasks to tick off", icon: "☐", type: "list_item", attrs: { kind: "check" }, hint: "[]", aliases: ["todo", "to-do", "task", "check", "checkbox"] },
27
+ { key: "toggle", label: "Toggle", description: "Hide details under a line", icon: "▸", type: "toggle", attrs: { open: true }, hint: ">", aliases: ["collapse", "details", "fold"] },
28
+ { key: "callout", label: "Callout", description: "Make a note stand out", icon: "!", type: "callout", aliases: ["note", "aside", "info"] },
29
+ { key: "code", label: "Code", description: "Code with highlighting", icon: "</>", type: "code_block", hint: "```", aliases: ["pre", "snippet"] },
30
+ ];
31
+
32
+ export const INSERT_ITEMS = [
33
+ { key: "divider", label: "Divider", description: "A line between sections", icon: "—", hint: "---", aliases: ["hr", "line", "separator", "rule"], insert: "divider" },
34
+ { key: "image", label: "Image", description: "Upload or paste a picture", icon: "▣", aliases: ["img", "picture", "photo"], insert: "image" },
35
+ { key: "file", label: "File", description: "Attach any file", icon: "⎘", aliases: ["upload", "attachment", "pdf"], insert: "file" },
36
+ { key: "table", label: "Table", description: "Rows and columns, paste from a spreadsheet", icon: "▦", aliases: ["grid", "spreadsheet"], insert: "table" },
37
+ { key: "csv", label: "Import CSV…", description: "Turn a CSV file into a table", icon: "⇧", aliases: ["comma separated", "import"], insert: "csv" },
38
+ { key: "board", label: "Board", description: "Cards in columns, like a kanban", icon: "▥", aliases: ["kanban", "cards", "columns", "status"], insert: "board" },
39
+ ];
40
+
41
+ export const CHART_ITEM = { key: "chart", label: "Chart", description: "A bar or line chart from a table", icon: "📊", aliases: ["graph", "plot", "bar", "line"], insert: "chart" };
42
+
43
+ export const MENU_ITEMS = [...BLOCK_TYPES, ...INSERT_ITEMS];
44
+
45
+ export const insertItemsFor = (st, items = MENU_ITEMS) => (chartTables(st.doc).length ? [...items, CHART_ITEM] : items);
46
+
47
+ // Which entry of BLOCK_TYPES a block is, for the toolbar's "Text ▾" label.
48
+ export function blockTypeOf(node) {
49
+ const t = node.type.name;
50
+ return BLOCK_TYPES.find((b) => b.type === t && (!b.attrs
51
+ || (t === "heading" ? b.attrs.level === node.attrs.level : t === "list_item" ? b.attrs.kind === node.attrs.kind : true))) ?? BLOCK_TYPES[0];
52
+ }
53
+
54
+ const startOf = (doc, index) => {
55
+ let at = 0;
56
+ for (let i = 0; i < index; i++) at += doc.child(i).nodeSize;
57
+ return at;
58
+ };
59
+
60
+ // Retype the top-level textblock at `index` of tr.doc into `item`. It keeps its
61
+ // id, pos and parent; a list item turned into another list kind keeps its indent.
62
+ export function retype(tr, index, item) {
63
+ const at = startOf(tr.doc, index);
64
+ const node = tr.doc.child(index);
65
+ const keep = { id: node.attrs.id, pos: node.attrs.pos, parent: node.attrs.parent };
66
+ if (node.type === N.list_item && item.type === "list_item") keep.indent = node.attrs.indent;
67
+ return tr.setBlockType(at + 1, at + node.nodeSize - 1, N[item.type], { ...keep, ...(item.attrs || {}) });
68
+ }
69
+
70
+ export function turnInto(st, index, item) {
71
+ return st.doc.child(index).isTextblock ? retype(st.tr, index, item) : null;
72
+ }
73
+
74
+ // A new empty paragraph after the block at `index` and its subtree, at the
75
+ // block's own level, with the caret in it.
76
+ export function insertAfter(st, index, tr = st.tr) {
77
+ const ents = outline(tr.doc);
78
+ const { to } = subtreeRange(ents, index);
79
+ const node = tr.doc.child(index);
80
+ tr.insert(to, N.paragraph.create({ parent: node.attrs.parent ?? null }));
81
+ return tr.setSelection(TextSelection.create(tr.doc, to + 1));
82
+ }
83
+
84
+ // A key strictly between two neighbours, or null when either is not a usable
85
+ // key (identityPlugin then repairs it).
86
+ const keyAfter = (pos, next) => (isKey(pos) && (next === null || (isKey(next) && pos < next)) ? keyBetween(pos, next) : null);
87
+
88
+ // The empty paragraph at `index` becomes a divider that keeps its id, pos and
89
+ // parent, followed by a new empty paragraph holding the caret. The new
90
+ // paragraph is given its id and key here, so the identity plugin appends no
91
+ // transaction after this one (which would end undoInputRule for a `---` rule).
92
+ export function replaceWithDivider(tr, index) {
93
+ const at = startOf(tr.doc, index);
94
+ const node = tr.doc.child(index);
95
+ const next = tr.doc.maybeChild(index + 1);
96
+ const { id, pos, parent } = node.attrs;
97
+ tr.replaceWith(at, at + node.nodeSize, [
98
+ N.divider.create({ id, pos, parent }),
99
+ N.paragraph.create({ id: newId(), pos: keyAfter(pos, next ? next.attrs.pos : null), parent }),
100
+ ]);
101
+ return tr.setSelection(TextSelection.create(tr.doc, at + 2));
102
+ }
103
+
104
+ // Insert a divider for the block at `index`: in place of an empty paragraph,
105
+ // otherwise after the block's whole subtree at the block's own level.
106
+ export function insertDivider(st, index, tr = st.tr) {
107
+ const node = tr.doc.child(index);
108
+ if (node.type === N.paragraph && node.content.size === 0) return replaceWithDivider(tr, index);
109
+ const ents = outline(tr.doc), end = ents[index].subEnd;
110
+ const to = ents[end].end, next = ents[end + 1]?.node.attrs.pos ?? null;
111
+ const pos = keyAfter(ents[end].node.attrs.pos, next), parent = node.attrs.parent ?? null;
112
+ tr.insert(to, [
113
+ N.divider.create({ id: newId(), pos, parent }),
114
+ N.paragraph.create({ id: newId(), pos: keyAfter(pos, next), parent }),
115
+ ]);
116
+ return tr.setSelection(TextSelection.create(tr.doc, to + 2));
117
+ }
118
+
119
+ // Exact name or alias first, then label prefix, word or alias prefix,
120
+ // substring; equal ranks keep menu order. No fuzzy matching.
121
+ export function rankItems(items, query) {
122
+ const q = query.trim().toLowerCase();
123
+ if (!q) return items;
124
+ const rank = (it) => {
125
+ const label = it.label.toLowerCase();
126
+ const names = [it.key, ...(it.aliases || []), ...(it.hint ? [it.hint] : [])].map((s) => s.toLowerCase());
127
+ if (label === q || names.includes(q)) return 0;
128
+ if (label.startsWith(q)) return 1;
129
+ if ([...label.split(/\s+/), ...names].some((s) => s.startsWith(q))) return 2;
130
+ if ([label, ...names].some((s) => s.includes(q))) return 3;
131
+ if (q.length >= 3 && it.description && it.description.toLowerCase().split(/[^a-z0-9]+/).some((w) => w.startsWith(q))) return 4;
132
+ return Infinity;
133
+ };
134
+ return items.map((it, order) => ({ it, order, rank: rank(it) })).filter((x) => x.rank !== Infinity).sort((a, b) => a.rank - b.rank || a.order - b.order).map((x) => x.it);
135
+ }
136
+
137
+ // What a pick does in the insert menu for the block at `index`. A divider and
138
+ // the file pickers have their own placement; a block type retypes an empty line
139
+ // and otherwise goes into a new block below the line's whole subtree.
140
+ export function runItem(view, item, index, remove = null) {
141
+ const st = view.state;
142
+ if (item.insert === "image" || item.insert === "file") return pickFiles(view, item.insert === "image" ? "image/*" : "", hostPlace(st, index), remove);
143
+ if (item.insert === "csv") return pickCsv(view, remove);
144
+ if (item.insert === "table") return insertTableNode(st, view.dispatch, makeTable(), remove);
145
+ if (item.insert === "board") return insertBoardNode(st, view.dispatch, makeBoard(), remove);
146
+ if (item.insert === "chart") return chartFromSlash(view, index, remove);
147
+ let tr = st.tr;
148
+ if (remove) tr.delete(remove.from, remove.to);
149
+ const node = tr.doc.child(index);
150
+ if (item.insert === "divider") tr = insertDivider(st, index, tr);
151
+ else if (node.isTextblock && node.content.size === 0) tr = retype(tr, index, item);
152
+ else {
153
+ tr = insertAfter(st, index, tr);
154
+ tr = retype(tr, tr.doc.resolve(tr.selection.head).index(0), item);
155
+ }
156
+ view.dispatch(asStep(tr).scrollIntoView());
157
+ view.focus();
158
+ return true;
159
+ }