@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.
- package/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69667 -41708
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +94 -93
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +7 -3
- package/src/loader.js +6 -0
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1121 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +384 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/live-sync.js +13 -2
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- 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
|
+
}
|
package/src/page/inline.js
CHANGED
|
@@ -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
|
+
}
|