@panphora/clayjs 1.13.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69667 -41708
  3. package/dist/server/checkpoint.cjs +70690 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/rooms.cjs +72039 -0
  6. package/dist/server/rooms.mjs +66 -0
  7. package/docs/guide.md +94 -93
  8. package/entries/clay.js +22 -0
  9. package/package.json +16 -2
  10. package/packed-contract.json +17 -3
  11. package/src/core/conflict-download.js +11 -2
  12. package/src/core/host-meta.js +75 -0
  13. package/src/core/persist.js +11 -0
  14. package/src/core/save-core.js +266 -31
  15. package/src/core/save.js +62 -32
  16. package/src/core/snapshot.js +39 -13
  17. package/src/core/style-comparisons.js +37 -0
  18. package/src/dom/insert-style-tag.js +11 -12
  19. package/src/lib/effect.js +130 -0
  20. package/src/lib/style-adoption.js +9 -0
  21. package/src/lib/style.js +44 -0
  22. package/src/live/fast-lane.js +347 -60
  23. package/src/live/local-lane.js +32 -6
  24. package/src/live/ops.js +77 -0
  25. package/src/live/provenance.js +129 -0
  26. package/src/live/save-history.js +188 -0
  27. package/src/live/server-lane.js +9 -2
  28. package/src/live/store.js +194 -16
  29. package/src/loader-logic.js +7 -3
  30. package/src/loader.js +6 -0
  31. package/src/page/address.js +20 -0
  32. package/src/page/adopt-boot.js +189 -0
  33. package/src/page/adopt.js +240 -0
  34. package/src/page/ai-tool.js +188 -0
  35. package/src/page/announce.js +21 -0
  36. package/src/page/blocks.js +1121 -0
  37. package/src/page/blocksel.js +269 -0
  38. package/src/page/board-drag.js +226 -0
  39. package/src/page/board-panel.js +353 -0
  40. package/src/page/board-style.js +146 -0
  41. package/src/page/board-view.js +447 -0
  42. package/src/page/board.js +41 -0
  43. package/src/page/chart.js +220 -0
  44. package/src/page/checklist.js +143 -0
  45. package/src/page/chrome.js +587 -0
  46. package/src/page/csv-import.js +169 -0
  47. package/src/page/drag.js +202 -0
  48. package/src/page/drop.js +112 -0
  49. package/src/page/editor.js +384 -0
  50. package/src/page/highlight.js +268 -0
  51. package/src/page/inline.js +60 -1
  52. package/src/page/items.js +159 -0
  53. package/src/page/link.js +247 -0
  54. package/src/page/markdown.js +71 -0
  55. package/src/page/media.js +570 -0
  56. package/src/page/merge.js +130 -0
  57. package/src/page/model.js +71 -9
  58. package/src/page/placeholder.js +23 -0
  59. package/src/page/projection.js +647 -0
  60. package/src/page/recover.js +193 -0
  61. package/src/page/rich/cell.js +16 -0
  62. package/src/page/rich/chart.js +104 -0
  63. package/src/page/rich/csv.js +118 -0
  64. package/src/page/rich/page-tool.js +86 -0
  65. package/src/page/rich/tool-runtime.js +195 -0
  66. package/src/page/save-projection.js +53 -0
  67. package/src/page/save-states.js +85 -0
  68. package/src/page/schema.js +12 -5
  69. package/src/page/table-kinds.js +20 -0
  70. package/src/page/table-paste.js +57 -0
  71. package/src/page/table-select.js +244 -0
  72. package/src/page/table-style.js +25 -0
  73. package/src/page/table.js +650 -0
  74. package/src/page/themes/content.css +46 -0
  75. package/src/page/themes/light-1.css +269 -0
  76. package/src/page/themes/light-2.css +211 -0
  77. package/src/page/themes/light-3.css +130 -0
  78. package/src/page/themes/light-4.css +299 -0
  79. package/src/page/tool-data.js +25 -0
  80. package/src/page/tool-view.js +68 -0
  81. package/src/page/tools.js +133 -0
  82. package/src/page/view.js +136 -0
  83. package/src/page/ybind/author.js +25 -0
  84. package/src/page/ybind/binding.js +92 -0
  85. package/src/page/ybind/board-commands.js +170 -0
  86. package/src/page/ybind/board.js +89 -0
  87. package/src/page/ybind/bridge.js +382 -0
  88. package/src/page/ybind/conversion.js +26 -0
  89. package/src/page/ybind/core.js +7 -0
  90. package/src/page/ybind/decode.js +208 -0
  91. package/src/page/ybind/doc.js +58 -0
  92. package/src/page/ybind/external.js +190 -0
  93. package/src/page/ybind/group.js +124 -0
  94. package/src/page/ybind/index.js +12 -0
  95. package/src/page/ybind/layout.js +27 -0
  96. package/src/page/ybind/marks.js +18 -0
  97. package/src/page/ybind/migrate.js +152 -0
  98. package/src/page/ybind/moves.js +288 -0
  99. package/src/page/ybind/origins.js +257 -0
  100. package/src/page/ybind/parts.js +44 -0
  101. package/src/page/ybind/projection-map.js +92 -0
  102. package/src/page/ybind/provider.js +285 -0
  103. package/src/page/ybind/recovery.js +411 -0
  104. package/src/page/ybind/seed.js +27 -0
  105. package/src/page/ybind/table.js +166 -0
  106. package/src/page/ybind/tokens.js +93 -0
  107. package/src/page/ybind/walk.js +333 -0
  108. package/src/page/ybind/writer.js +307 -0
  109. package/src/plugins/indicator.js +16 -6
  110. package/src/plugins/live.js +742 -65
  111. package/src/plugins/page.js +547 -0
  112. package/src/plugins/undo.js +60 -3
  113. package/src/server/checkpoint/index.js +218 -0
  114. package/src/server/rooms/index.js +59 -0
  115. package/src/server/rooms/live-data-ops.js +369 -0
  116. package/src/server/rooms/live-data-rooms.js +965 -0
  117. package/src/server/rooms/live-data-tags.js +49 -0
  118. package/src/server/rooms/live-page-history.js +41 -0
  119. package/src/server/rooms/live-provenance.js +142 -0
  120. package/src/server/rooms/page-admission.js +79 -0
  121. package/src/server/rooms/page-conversion.js +62 -0
  122. package/src/server/rooms/page-rebase.js +108 -0
  123. package/src/server/rooms/page-state.js +91 -0
  124. package/src/server/rooms/page-write-admission.js +63 -0
  125. package/src/server/rooms/page-write-guard.js +23 -0
  126. package/src/server/rooms/recent.js +18 -0
  127. package/src/server/rooms/spec-wire.js +17 -0
  128. package/src/standalone.js +22 -0
  129. package/src/sync/live-sync.js +13 -2
  130. package/src/sync/stream.js +62 -16
  131. package/src/ui/index.js +4 -0
  132. package/src/ui/toast.js +21 -5
  133. package/src/vendor/pierre/pierre.vendor.js +3 -0
  134. package/src/vendor/yjs.vendor.js +7 -0
  135. package/src/page/binding.js +0 -647
  136. package/src/page/replay.js +0 -578
@@ -0,0 +1,587 @@
1
+ // The page editor's chrome: a faint toolbar, a plus button beside the caret's
2
+ // line, a toolbar over a text selection and a block menu (from the plus or "/").
3
+ // At rest everything is faint; moving the pointer adds .is-moved to the shell,
4
+ // which themes darken by a small step. All of it is themed through the
5
+ // --page-* tokens in editor.js (contract: default-page/v1/look/THEME-CONTRACT.md).
6
+ import { state, commands } from "../vendor/prosemirror.vendor.js";
7
+ import { schema } from "./schema.js";
8
+ import { BlockSelection, outline, siblingSpan, moveBlock, indentBlocks, outdentBlocks } from "./blocks.js";
9
+ import { currentSpan, selectBlocks, duplicateBlocks, deleteBlocks, asStep, typeIntoBlocks } from "./blocksel.js";
10
+ import { openAltEditor, setImageAttrs } from "./media.js";
11
+ import { BlockDrag } from "./drag.js";
12
+ import { openLinkEditor } from "./link.js";
13
+ import { BLOCK_TYPES, MENU_ITEMS, blockTypeOf, turnInto, insertAfter, rankItems, runItem, retype, insertItemsFor } from "./items.js";
14
+ import toast from "../ui/toast.js";
15
+ import { insertChartAfterTable } from "./chart.js";
16
+ import { pickCsv } from "./csv-import.js";
17
+
18
+ export { BLOCK_TYPES, blockTypeOf, turnInto, insertAfter } from "./items.js";
19
+
20
+ const { Plugin, PluginKey, TextSelection, NodeSelection } = state;
21
+ const M = schema.marks;
22
+ const N = schema.nodes;
23
+
24
+ const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl+";
25
+
26
+ const selectedRoots = (st) => {
27
+ const ents = outline(st.doc);
28
+ const { lo, hi } = st.selection.span(st.doc);
29
+ return ents.slice(lo, hi + 1).filter((e) => e.up === ents[lo].up);
30
+ };
31
+
32
+ const imageId = (view, index) => view.state.doc.child(index).attrs.id;
33
+ const blockStart = (doc, index) => { let at = 0; for (let i = 0; i < index; i++) at += doc.child(i).nodeSize; return at; };
34
+
35
+ export function blockLink(id, loc = globalThis.location) {
36
+ return `${loc.origin}${loc.pathname}${loc.search}#${encodeURIComponent(id)}`;
37
+ }
38
+
39
+ function copyBlockLink(id) {
40
+ if (!id) return;
41
+ const done = () => toast("Link copied");
42
+ const failed = () => toast("Could not copy the link.", "error");
43
+ const write = globalThis.navigator?.clipboard?.writeText;
44
+ if (!write) { failed(); return; }
45
+ globalThis.navigator.clipboard.writeText(blockLink(id)).then(done, failed);
46
+ }
47
+
48
+ export const ACTION_ITEMS = [
49
+ { key: "up", label: "Move up", icon: "↑", hint: `${MOD}⇧↑`, run: (view) => moveBlock(-1)(view.state, view.dispatch) },
50
+ { key: "down", label: "Move down", icon: "↓", hint: `${MOD}⇧↓`, run: (view) => moveBlock(1)(view.state, view.dispatch) },
51
+ { key: "indent", label: "Indent", icon: "→", hint: "Tab", run: (view) => indentBlocks(view.state, view.dispatch) },
52
+ { key: "outdent", label: "Outdent", icon: "←", hint: "⇧Tab", run: (view) => outdentBlocks(view.state, view.dispatch) },
53
+ { key: "duplicate", label: "Duplicate", icon: "⧉", hint: `${MOD}D`, run: (view) => duplicateBlocks(view.state, view.dispatch) },
54
+ { key: "delete", label: "Delete", icon: "×", hint: "Del", run: (view) => deleteBlocks(view.state, view.dispatch) },
55
+ { key: "turn", label: "Turn into", icon: "↻", hint: "›", only: "text", run: (view, chrome) => chrome.openMenu("turn", chrome.plus) },
56
+ { key: "link", label: "Copy link", icon: "🔗", run: (view, chrome, index) => copyBlockLink(view.state.doc.child(index).attrs.id) },
57
+ { key: "alt", label: "Alt text…", icon: "Aa", only: "image", focus: false, run: (view, chrome, index) => openAltEditor(view, imageId(view, index)) },
58
+ { key: "small", label: "Small", icon: "▫", only: "image", run: (view, chrome, index) => setImageAttrs(view, imageId(view, index), { width: 240 }) },
59
+ { key: "medium", label: "Medium", icon: "◻", only: "image", run: (view, chrome, index) => setImageAttrs(view, imageId(view, index), { width: 480 }) },
60
+ { key: "full", label: "Full width", icon: "⬜", only: "image", run: (view, chrome, index) => setImageAttrs(view, imageId(view, index), { width: null }) },
61
+ { key: "chart", label: "Chart this table", icon: "📊", only: "table", run: (view, chrome, index) => insertChartAfterTable(view, blockStart(view.state.doc, index)) },
62
+ { key: "csv", label: "Import CSV…", icon: "⇧", only: "table", focus: false, run: (view, chrome, index) => pickCsv(view, null, view.state.doc.child(index).attrs.id) },
63
+ ];
64
+
65
+ const actionsFor = (st) => {
66
+ if (!(st.selection instanceof BlockSelection)) return [];
67
+ const roots = selectedRoots(st);
68
+ const text = roots.every((e) => e.node.isTextblock);
69
+ const image = roots.length === 1 && roots[0].node.type === N.image;
70
+ const table = roots.length === 1 && roots[0].node.type === N.table;
71
+ return ACTION_ITEMS.filter((a) => (a.only === "text" ? text : a.only === "image" ? image : a.only === "table" ? table : true));
72
+ };
73
+
74
+ const svg = (body) => `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
75
+ const ICONS = {
76
+ plus: svg('<path d="M8 3.5v9M3.5 8h9"/>'),
77
+ grip: svg('<path stroke-width="2.2" d="M6 3.5h.01M10 3.5h.01M6 8h.01M10 8h.01M6 12.5h.01M10 12.5h.01"/>'),
78
+ strong: svg('<path d="M5 3h4a2.5 2.5 0 0 1 0 5H5zM5 8h4.6a2.5 2.5 0 0 1 0 5H5z"/>'),
79
+ em: svg('<path d="M10 3H6.5M9.5 13H6M9 3l-2 10"/>'),
80
+ strike: svg('<path d="M3 8h10M10.8 5.2C10.5 4 9.4 3.2 8 3.2c-1.7 0-2.8.9-2.8 2.1M5.2 10.8c.3 1.2 1.4 2 2.8 2 1.7 0 2.8-.9 2.8-2.1"/>'),
81
+ code: svg('<path d="M6 4.5 2.5 8 6 11.5M10 4.5 13.5 8 10 11.5"/>'),
82
+ link: svg('<path d="M7 9a2.5 2.5 0 0 0 3.5 0l2-2A2.5 2.5 0 0 0 9 3.5l-.7.7M9 7a2.5 2.5 0 0 0-3.5 0l-2 2A2.5 2.5 0 0 0 7 12.5l.7-.7"/>'),
83
+ bullet: svg('<path d="M6.5 4.5h7M6.5 8h7M6.5 11.5h7"/><path stroke-width="2.4" d="M3 4.5h.01M3 8h.01M3 11.5h.01"/>'),
84
+ ordered: svg('<path d="M7 4.5h6.5M7 8h6.5M7 11.5h6.5M2.5 3.5l1-.5v3M2.5 9.3c.2-.5.6-.8 1.1-.8.6 0 1 .4 1 .9 0 .9-2.1 1.4-2.1 2.6h2.1"/>'),
85
+ check: svg('<rect x="2.5" y="2.5" width="11" height="11" rx="2.5"/><path d="M5.5 8.2l1.8 1.8 3.4-4"/>'),
86
+ };
87
+ const TOOLS = [
88
+ ["strong", "Bold"],
89
+ ["em", "Italic"],
90
+ ["strike", "Strikethrough"],
91
+ ["link", "Link"],
92
+ ["bullet", "Bulleted list"],
93
+ ["ordered", "Numbered list"],
94
+ ["check", "Checklist"],
95
+ ["code", "Code"],
96
+ ];
97
+ const LIST_KINDS = new Set(["bullet", "ordered", "check"]);
98
+ const LINE_TYPES = new Set(["paragraph", "heading", "list_item"]);
99
+
100
+ // The top-level blocks a toolbar command acts on. A nonempty selection's end is
101
+ // exclusive, so a block that only starts there is left out.
102
+ const blocksIn = (st) => {
103
+ const sel = st.selection;
104
+ if (sel instanceof BlockSelection) return selectedRoots(st).map((e) => e.i);
105
+ const { $from, $to, empty } = sel;
106
+ const last = Math.min(!empty && $to.depth === 0 ? $to.index(0) - 1 : $to.index(0), st.doc.childCount - 1);
107
+ const out = [];
108
+ for (let i = $from.index(0); i <= last; i++) out.push(i);
109
+ return out;
110
+ };
111
+
112
+ const listable = (st) => blocksIn(st).filter((i) => { const n = st.doc.child(i); return n.isTextblock && n.type !== N.code_block; });
113
+ const isKind = (node, kind) => node.type === N.list_item && node.attrs.kind === kind;
114
+ const listActive = (st, kind) => { const idx = listable(st); return idx.length > 0 && idx.every((i) => isKind(st.doc.child(i), kind)); };
115
+
116
+ const lineText = (node) => {
117
+ let text = "";
118
+ node.forEach((c) => { text += c.isText ? c.text : c.type === N.hard_break ? "\n" : ""; });
119
+ return text;
120
+ };
121
+
122
+ // The selection covers whole lines when it runs from the start of its first
123
+ // block to the end of its last, and every block is a plain line of text.
124
+ function wholeLines(st) {
125
+ const { $from, $to, empty } = st.selection;
126
+ if (empty || !(st.selection instanceof TextSelection) || $from.depth !== 1 || $to.depth !== 1) return null;
127
+ if ($from.parentOffset !== 0 || $to.parentOffset !== $to.parent.content.size) return null;
128
+ const idx = blocksIn(st);
129
+ return idx.every((i) => LINE_TYPES.has(st.doc.child(i).type.name)) ? idx : null;
130
+ }
131
+
132
+ function codeBlockTr(st, idx) {
133
+ const first = st.doc.child(idx[0]);
134
+ const from = st.selection.$from.before(1);
135
+ const to = st.selection.$to.after(1);
136
+ const text = idx.map((i) => lineText(st.doc.child(i))).join("\n");
137
+ const { id, pos, parent } = first.attrs;
138
+ const tr = st.tr.replaceWith(from, to, N.code_block.create({ id, pos, parent }, text ? schema.text(text) : null));
139
+ return tr.setSelection(TextSelection.create(tr.doc, from + 1, from + 1 + text.length));
140
+ }
141
+
142
+ // The way back: each selected code block becomes one text line per line of code.
143
+ // The first line keeps the block's id; the rest get new ids from the identity plugin.
144
+ function codeLinesTr(st, idx) {
145
+ const tr = st.tr;
146
+ const from = st.doc.resolve(st.selection.from).before(1);
147
+ let end = 0;
148
+ for (const i of [...idx].reverse()) {
149
+ let at = 0;
150
+ for (let k = 0; k < i; k++) at += st.doc.child(k).nodeSize;
151
+ const node = st.doc.child(i);
152
+ const { id, pos, parent } = node.attrs;
153
+ const lines = node.textContent.split("\n").map((line, k) => N.paragraph.create(k === 0 ? { id, pos, parent } : { parent }, line ? schema.text(line) : null));
154
+ tr.replaceWith(at, at + node.nodeSize, lines);
155
+ if (!end) end = at + lines.reduce((n, l) => n + l.nodeSize, 0);
156
+ }
157
+ end = tr.mapping.slice(1).map(end);
158
+ return tr.setSelection(TextSelection.create(tr.doc, from + 1, end - 1));
159
+ }
160
+
161
+ const allCode = (st) => { const idx = blocksIn(st); return idx.length > 0 && idx.every((i) => st.doc.child(i).type === N.code_block) ? idx : null; };
162
+
163
+ function toolActive(st, name) {
164
+ if (LIST_KINDS.has(name)) return listActive(st, name);
165
+ if (name === "code" && allCode(st)) return true;
166
+ const type = M[name];
167
+ const { from, to, empty, $from } = st.selection;
168
+ if (empty) return !!type.isInSet(st.storedMarks || $from.marks());
169
+ return st.doc.rangeHasMark(from, to, type);
170
+ }
171
+
172
+ function runList(view, kind) {
173
+ const st = view.state;
174
+ const off = listActive(st, kind);
175
+ const item = BLOCK_TYPES.find((b) => b.key === (off ? "text" : kind));
176
+ const tr = st.tr;
177
+ for (const i of listable(st)) if (off || !isKind(tr.doc.child(i), kind)) retype(tr, i, item);
178
+ if (tr.docChanged) view.dispatch(asStep(tr));
179
+ }
180
+
181
+ function runMark(view, name) {
182
+ if (name === "link") return openLinkEditor(view);
183
+ const st = view.state;
184
+ const code = name === "code" ? allCode(st) : null;
185
+ const lines = name === "code" && !code ? wholeLines(st) : null;
186
+ if (LIST_KINDS.has(name)) runList(view, name);
187
+ else if (code) view.dispatch(asStep(codeLinesTr(st, code)).scrollIntoView());
188
+ else if (lines) view.dispatch(asStep(codeBlockTr(st, lines)).scrollIntoView());
189
+ else commands.toggleMark(M[name])(st, view.dispatch);
190
+ view.focus();
191
+ }
192
+
193
+ const el = (tag, cls, attrs = {}) => {
194
+ const e = document.createElement(tag);
195
+ if (cls) e.className = cls;
196
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
197
+ return e;
198
+ };
199
+
200
+ function toolButton(name, label) {
201
+ const b = el("button", "page-tool", { type: "button", "data-cmd": name, "aria-label": label, title: label });
202
+ b.innerHTML = ICONS[name];
203
+ return b;
204
+ }
205
+
206
+ const MOVED_MS = 1600;
207
+ let menuCount = 0;
208
+ export const chromeKey = new PluginKey("page-chrome");
209
+ const chromeFor = new WeakMap();
210
+
211
+ export function openPageMenu(view, anchor, items) {
212
+ const chrome = chromeFor.get(view);
213
+ if (!chrome) return false;
214
+ chrome.openMenu("external", anchor);
215
+ chrome.menu.externalItems = items;
216
+ chrome.renderMenu();
217
+ return true;
218
+ }
219
+
220
+ class Chrome {
221
+ constructor(view, onAiTool) {
222
+ this.view = view;
223
+ this.onAiTool = onAiTool;
224
+ this.shell = view.dom.parentElement;
225
+ this.menu = null;
226
+ this.movedTimer = 0;
227
+ this.hover = null;
228
+
229
+ this.dock = el("div", "page-dock", { role: "toolbar", "aria-label": "Formatting" });
230
+ this.turn = el("button", "page-tool page-tool-turn", { type: "button", "aria-haspopup": "menu" });
231
+ this.dock.append(this.turn, el("span", "page-tool-sep"), ...TOOLS.map(([n, l]) => toolButton(n, l)));
232
+ this.plus = el("button", "page-plus", { type: "button", "aria-label": "Add a block", title: "Add a block" });
233
+ this.plus.innerHTML = ICONS.plus;
234
+ this.grip = el("button", "page-grip", { type: "button", "aria-label": "Block actions", title: "Drag to move, click for actions", "aria-haspopup": "menu" });
235
+ this.grip.innerHTML = ICONS.grip;
236
+ this.bubble = el("div", "page-bubble", { role: "toolbar", "aria-label": "Selection", hidden: "" });
237
+ this.bubble.append(...TOOLS.map(([n, l]) => toolButton(n, l)));
238
+ this.menuId = `page-menu-${++menuCount}`;
239
+ this.menuEl = el("div", "page-menu", { role: "menu", id: this.menuId, hidden: "" });
240
+ this.scrim = el("div", "page-scrim", { hidden: "" });
241
+ this.shell.insertBefore(this.dock, view.dom);
242
+ this.shell.append(this.plus, this.grip, this.bubble, this.scrim, this.menuEl);
243
+
244
+ const keepFocus = (e) => e.preventDefault();
245
+ for (const part of [this.dock, this.plus, this.grip, this.bubble, this.menuEl]) part.addEventListener("mousedown", keepFocus);
246
+ for (const bar of [this.dock, this.bubble]) {
247
+ bar.addEventListener("click", (e) => {
248
+ const b = e.target.closest("[data-cmd]");
249
+ if (b) runMark(this.view, b.dataset.cmd);
250
+ });
251
+ }
252
+ this.turn.addEventListener("click", () => this.toggleMenu("turn", this.turn));
253
+ this.plus.addEventListener("click", () => this.fromPlus());
254
+ this.grip.addEventListener("click", (e) => {
255
+ if (e.detail === 0) this.gripAction(this.gutterIndex());
256
+ });
257
+ this.drag = new BlockDrag(view, this.grip, { index: () => this.gutterIndex(), onClick: (i) => this.gripAction(i) });
258
+ this.scrim.addEventListener("click", () => this.closeMenu());
259
+ this.menuEl.addEventListener("click", (e) => {
260
+ const row = e.target.closest(".page-menu-item");
261
+ if (row) this.pick(Number(row.dataset.index));
262
+ });
263
+ this.menuEl.addEventListener("keydown", (e) => {
264
+ if (this.key(e)) e.preventDefault();
265
+ });
266
+ this.onMove = (e) => {
267
+ this.shell.classList.add("is-moved");
268
+ clearTimeout(this.movedTimer);
269
+ this.movedTimer = setTimeout(() => this.shell.classList.remove("is-moved"), MOVED_MS);
270
+ this.trackHover(e);
271
+ };
272
+ this.onKey = () => {
273
+ clearTimeout(this.movedTimer);
274
+ this.shell.classList.remove("is-moved");
275
+ this.setHover(null);
276
+ };
277
+ this.onLeave = () => this.setHover(null);
278
+ this.onResize = () => this.place();
279
+ this.onDocDown = (e) => {
280
+ if (!this.menu) return;
281
+ if ([this.menuEl, this.plus, this.grip, this.turn].some((part) => part.contains(e.target))) return;
282
+ this.closeMenu();
283
+ };
284
+ document.addEventListener("mousedown", this.onDocDown, true);
285
+ this.shell.addEventListener("mousemove", this.onMove);
286
+ this.shell.addEventListener("mouseleave", this.onLeave);
287
+ this.shell.addEventListener("keydown", this.onKey);
288
+ window.addEventListener("resize", this.onResize);
289
+ this.update(view, null);
290
+ }
291
+
292
+ blockIndex() {
293
+ const st = this.view.state, sel = st.selection;
294
+ const pos = sel instanceof BlockSelection || sel instanceof NodeSelection ? sel.from : sel.head;
295
+ return Math.min(st.doc.resolve(pos).index(0), st.doc.childCount - 1);
296
+ }
297
+
298
+ gutterIndex() {
299
+ if (this.view.dom.ownerDocument.defaultView.matchMedia?.("(max-width: 560px)").matches) return this.blockIndex();
300
+ return Math.min(this.hover ?? this.blockIndex(), this.view.state.doc.childCount - 1);
301
+ }
302
+
303
+ gripAction(index) {
304
+ if (this.menu) this.closeMenu();
305
+ else this.openActions(this.grip, index);
306
+ }
307
+
308
+ trackHover(event) {
309
+ if (this.menu || this.drag.state || [this.plus, this.grip, this.dock, this.bubble].some((part) => part.contains(event.target))) return;
310
+ const view = this.view;
311
+ const hit = view.posAtCoords({ left: view.dom.getBoundingClientRect().left + 2, top: event.clientY });
312
+ if (!hit) return;
313
+ const doc = view.state.doc;
314
+ const ents = outline(doc);
315
+ let i = Math.min(doc.resolve(hit.inside >= 0 ? hit.inside : hit.pos).index(0), ents.length - 1);
316
+ while (i > 0 && ents[i].hidden) i--;
317
+ this.setHover(i);
318
+ }
319
+
320
+ setHover(i) {
321
+ if (i === this.hover) return;
322
+ this.hover = i;
323
+ this.place();
324
+ }
325
+
326
+ fromPlus() {
327
+ this.view.focus();
328
+ this.openMenu("insert", this.plus, null, this.gutterIndex());
329
+ }
330
+
331
+ toggleMenu(mode, anchor) {
332
+ if (this.menu) this.closeMenu();
333
+ else this.openMenu(mode, anchor);
334
+ }
335
+
336
+ openMenu(mode, anchor, slashAt = null, index = null) {
337
+ this.menu = { mode, anchor, slashAt, index, query: "", active: 0, items: [] };
338
+ anchor.setAttribute("aria-expanded", "true");
339
+ this.view.dom.setAttribute("aria-haspopup", "menu");
340
+ this.view.dom.setAttribute("aria-controls", this.menuId);
341
+ this.renderMenu();
342
+ }
343
+
344
+ closeMenu() {
345
+ this.menu?.anchor.setAttribute("aria-expanded", "false");
346
+ this.menu = null;
347
+ this.menuEl.hidden = true;
348
+ this.scrim.hidden = true;
349
+ for (const name of ["aria-haspopup", "aria-controls", "aria-activedescendant"]) this.view.dom.removeAttribute(name);
350
+ }
351
+
352
+ menuItems(m) {
353
+ if (m.mode === "external") return m.externalItems || [];
354
+ if (m.mode === "actions") return actionsFor(this.view.state);
355
+ const items = m.mode === "turn" ? BLOCK_TYPES : insertItemsFor(this.view.state, this.onAiTool
356
+ ? [...MENU_ITEMS, { key: "ai", label: "Make a tool with AI…", icon: "✦", aliases: ["tool", "generate"], insert: "ai" }]
357
+ : MENU_ITEMS);
358
+ return rankItems(items, m.query);
359
+ }
360
+
361
+ openActions(anchor = this.plus, at = null) {
362
+ const st = this.view.state;
363
+ const ents = outline(st.doc);
364
+ const sel = st.selection;
365
+ const inside = at !== null && sel instanceof BlockSelection && sel.span(st.doc).lo <= at && at <= sel.span(st.doc).hi;
366
+ const { lo, hi } = at === null || inside ? currentSpan(st, ents) : siblingSpan(ents, at, at);
367
+ this.view.dispatch(st.tr.setSelection(selectBlocks(st.doc, lo, hi, ents)));
368
+ this.openMenu("actions", anchor, null, lo);
369
+ }
370
+
371
+ renderMenu() {
372
+ const m = this.menu;
373
+ m.items = this.menuItems(m);
374
+ m.active = Math.min(m.active, Math.max(0, m.items.length - 1));
375
+ const nodes = [];
376
+ if (m.mode === "turn") {
377
+ const title = el("div", "page-menu-title");
378
+ title.textContent = m.query ? `Turn into “${m.query}”` : "Turn into";
379
+ nodes.push(title);
380
+ }
381
+ if (!m.items.length) {
382
+ const none = el("div", "page-menu-empty");
383
+ none.textContent = "No matching blocks";
384
+ nodes.push(none);
385
+ }
386
+ m.items.forEach((b, i) => {
387
+ const active = i === m.active;
388
+ const row = el("button", `page-menu-item${active ? " is-active" : ""}`, { type: "button", role: typeof b.checked === "boolean" ? "menuitemcheckbox" : "menuitem", tabindex: m.mode === "external" ? "0" : "-1", id: `${this.menuId}-${i}`, "data-index": String(i), "aria-selected": active ? "true" : "false", ...(typeof b.checked === "boolean" ? { "aria-checked": String(b.checked) } : {}) });
389
+ const icon = el("span", "page-menu-icon");
390
+ icon.textContent = b.icon || "";
391
+ const label = el("span", "page-menu-label");
392
+ label.textContent = b.label;
393
+ if (b.description) {
394
+ const text = el("span", "page-menu-text");
395
+ const description = el("span", "page-menu-desc");
396
+ description.textContent = b.description;
397
+ text.append(label, description);
398
+ row.append(icon, text);
399
+ } else row.append(icon, label);
400
+ if (b.hint) {
401
+ const hint = el("span", "page-menu-hint");
402
+ hint.textContent = b.hint;
403
+ row.append(hint);
404
+ }
405
+ nodes.push(row);
406
+ });
407
+ this.menuEl.replaceChildren(...nodes);
408
+ if (m.items.length) this.view.dom.setAttribute("aria-activedescendant", `${this.menuId}-${m.active}`);
409
+ else this.view.dom.removeAttribute("aria-activedescendant");
410
+ this.menuEl.hidden = false;
411
+ this.scrim.hidden = false;
412
+ this.place();
413
+ if (m.mode === "external") this.menuEl.querySelector("button")?.focus();
414
+ }
415
+
416
+ pick(i) {
417
+ const m = this.menu;
418
+ const item = m?.items[i];
419
+ if (!item) return;
420
+ const st = this.view.state;
421
+ const remove = m.slashAt !== null && st.selection.head >= m.slashAt ? { from: m.slashAt - 1, to: st.selection.head } : null;
422
+ this.closeMenu();
423
+ if (m.mode === "turn") {
424
+ let tr = null;
425
+ if (st.selection instanceof BlockSelection) {
426
+ tr = st.tr;
427
+ for (const root of selectedRoots(st)) retype(tr, root.i, item);
428
+ } else tr = turnInto(st, this.blockIndex(), item);
429
+ if (tr) this.view.dispatch(asStep(tr).scrollIntoView());
430
+ this.view.focus();
431
+ } else if (m.mode === "actions" || m.mode === "external") {
432
+ item.run(this.view, this, m.index);
433
+ if (!this.menu && item.focus !== false) this.view.focus();
434
+ } else {
435
+ if (item.insert === "ai") this.onAiTool(this.view, remove);
436
+ else runItem(this.view, item, m.slashAt !== null ? this.blockIndex() : m.index, remove);
437
+ }
438
+ }
439
+
440
+ // Keys while the menu is open; anything else falls through to the editor.
441
+ key(event) {
442
+ if (event.isComposing || this.view.composing) return false;
443
+ const m = this.menu;
444
+ if (!m) return false;
445
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
446
+ if (m.items.length) {
447
+ m.active = (m.active + (event.key === "ArrowDown" ? 1 : -1) + m.items.length) % m.items.length;
448
+ this.renderMenu();
449
+ if (m.mode === "external") this.menuEl.querySelectorAll("button")[m.active]?.focus();
450
+ }
451
+ return true;
452
+ }
453
+ if (event.key === "Enter") {
454
+ if (m.items.length) this.pick(m.active);
455
+ return true;
456
+ }
457
+ if (event.key === "Escape") { this.closeMenu(); return true; }
458
+ return false;
459
+ }
460
+
461
+ // "/" typed at the start of a line or after a space opens the menu; the text
462
+ // typed after it filters. With no match the menu stays up saying so, until a
463
+ // space ends the session or Backspace brings a match back.
464
+ slashQuery() {
465
+ const m = this.menu;
466
+ if (!m || m.slashAt === null) return;
467
+ const { $head } = this.view.state.selection;
468
+ const start = $head.start();
469
+ if ($head.pos < m.slashAt || m.slashAt < start || this.view.state.doc.textBetween(m.slashAt - 1, m.slashAt) !== "/") return this.closeMenu();
470
+ m.query = this.view.state.doc.textBetween(m.slashAt, $head.pos);
471
+ m.active = 0;
472
+ if (/\s$/.test(m.query) && !this.menuItems(m).length) return this.closeMenu();
473
+ this.renderMenu();
474
+ }
475
+
476
+ place() {
477
+ const view = this.view;
478
+ const st = view.state;
479
+ const shellBox = this.shell.getBoundingClientRect();
480
+ const edBox = view.dom.getBoundingClientRect();
481
+ const i = this.gutterIndex();
482
+ let at = 0;
483
+ for (let k = 0; k < i; k++) at += st.doc.child(k).nodeSize;
484
+ const dom = view.nodeDOM(at);
485
+ if (dom && dom.getBoundingClientRect) {
486
+ const r = dom.getBoundingClientRect();
487
+ const lineH = parseFloat(getComputedStyle(dom).lineHeight) || r.height;
488
+ for (const part of [this.plus, this.grip]) {
489
+ part.style.setProperty("--page-plus-top", `${r.top - shellBox.top + Math.min(r.height, lineH) / 2}px`);
490
+ part.style.setProperty("--page-plus-left", `${r.left - shellBox.left}px`);
491
+ }
492
+ }
493
+ this.plus.style.setProperty("--page-text-left", `${edBox.left - shellBox.left}px`);
494
+
495
+ const sel = st.selection;
496
+ if (!this.bubble.hidden) {
497
+ const a = view.coordsAtPos(sel.from), b = view.coordsAtPos(sel.to, -1);
498
+ const mid = (Math.min(a.left, b.left) + Math.max(a.right, b.right)) / 2;
499
+ this.bubble.style.setProperty("--page-pop-x", `${mid - shellBox.left}px`);
500
+ this.bubble.style.setProperty("--page-pop-y", `${Math.min(a.top, b.top) - shellBox.top}px`);
501
+ }
502
+ if (this.menu) {
503
+ const m = this.menu;
504
+ let left, top;
505
+ if (m.slashAt !== null) {
506
+ const c = view.coordsAtPos(m.slashAt - 1);
507
+ left = c.left;
508
+ top = c.bottom + 6;
509
+ const height = this.menuEl.offsetHeight;
510
+ if (top + height > window.innerHeight - 8 && c.top - 6 - height >= 8) top = c.top - 6 - height;
511
+ } else {
512
+ const r = m.anchor === this.plus ? (dom?.getBoundingClientRect?.() ?? edBox) : m.anchor.getBoundingClientRect();
513
+ left = r.left;
514
+ top = r.bottom + 6;
515
+ }
516
+ this.menuEl.style.setProperty("--page-pop-x", `${Math.max(8, Math.min(left - shellBox.left, shellBox.width - 280))}px`);
517
+ this.menuEl.style.setProperty("--page-pop-y", `${top - shellBox.top}px`);
518
+ }
519
+ }
520
+
521
+ update(view, prev) {
522
+ this.view = view;
523
+ if (this.menu && this.menu.slashAt === null && prev && prev.doc !== view.state.doc) this.closeMenu();
524
+ const st = view.state;
525
+ const node = st.doc.child(this.blockIndex());
526
+ this.turn.textContent = `${blockTypeOf(node).label} ▾`;
527
+ for (const b of this.dock.querySelectorAll("[data-cmd]")) b.setAttribute("aria-pressed", String(toolActive(st, b.dataset.cmd)));
528
+ const showBubble = !st.selection.empty && st.selection instanceof TextSelection && view.hasFocus();
529
+ this.bubble.hidden = !showBubble;
530
+ if (showBubble) for (const b of this.bubble.querySelectorAll("[data-cmd]")) b.setAttribute("aria-pressed", String(toolActive(st, b.dataset.cmd)));
531
+ this.grip.classList.toggle("is-on", st.selection instanceof BlockSelection);
532
+ this.slashQuery();
533
+ this.place();
534
+ }
535
+
536
+ destroy() {
537
+ clearTimeout(this.movedTimer);
538
+ this.drag.destroy();
539
+ this.shell.removeEventListener("mousemove", this.onMove);
540
+ this.shell.removeEventListener("mouseleave", this.onLeave);
541
+ this.shell.removeEventListener("keydown", this.onKey);
542
+ window.removeEventListener("resize", this.onResize);
543
+ document.removeEventListener("mousedown", this.onDocDown, true);
544
+ for (const part of [this.dock, this.plus, this.grip, this.bubble, this.scrim, this.menuEl]) part.remove();
545
+ }
546
+ }
547
+
548
+ export function chromePlugin({ onAiTool = null } = {}) {
549
+ let chrome = null;
550
+ return new Plugin({
551
+ key: chromeKey,
552
+ view(view) {
553
+ chrome = new Chrome(view, onAiTool);
554
+ chromeFor.set(view, chrome);
555
+ return chrome;
556
+ },
557
+ props: {
558
+ handleKeyDown(view, event) {
559
+ if (!chrome) return false;
560
+ if (event.key === "/" && (event.metaKey || event.ctrlKey) && !event.altKey && !chrome.menu) {
561
+ event.preventDefault();
562
+ chrome.openActions();
563
+ return true;
564
+ }
565
+ return chrome.key(event);
566
+ },
567
+ handleTextInput(view, from, to, text) {
568
+ if (text !== "/" || !chrome) return false;
569
+ const inserted = typeIntoBlocks(view, text);
570
+ if (inserted) from = view.state.selection.from - 1;
571
+ const $from = view.state.doc.resolve(from);
572
+ if ($from.depth !== 1 || !$from.parent.isTextblock || $from.parent.type.spec.code) return inserted;
573
+ if ($from.parentOffset > 0 && !/\s/.test(view.state.doc.textBetween(from - 1, from, "", "\n"))) return inserted;
574
+ if (!inserted) view.dispatch(view.state.tr.insertText("/", from, to));
575
+ chrome.openMenu("insert", chrome.plus, from + 1);
576
+ return true;
577
+ },
578
+ handleDOMEvents: {
579
+ keydown(view, event) { return event.isComposing || view.composing; },
580
+ blur(view) {
581
+ if (chrome) chrome.bubble.hidden = true;
582
+ return false;
583
+ },
584
+ },
585
+ },
586
+ });
587
+ }