@canvist/web 0.0.0-stage → 0.2.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/dist/collab.d.ts +99 -0
- package/dist/collab.js +211 -0
- package/dist/edit-context.d.ts +59 -0
- package/dist/edit-context.js +16 -0
- package/dist/editor.d.ts +301 -0
- package/dist/editor.js +776 -0
- package/dist/font-watcher.d.ts +28 -0
- package/dist/font-watcher.js +88 -0
- package/dist/fonts.d.ts +130 -0
- package/dist/fonts.js +343 -0
- package/dist/full.d.ts +40 -0
- package/dist/full.js +25 -0
- package/dist/input.d.ts +68 -0
- package/dist/input.js +551 -0
- package/dist/load.d.ts +9 -0
- package/dist/load.js +21 -0
- package/dist/measure.d.ts +56 -0
- package/dist/measure.js +524 -0
- package/dist/menus.d.ts +71 -0
- package/dist/menus.js +173 -0
- package/dist/mirror.d.ts +24 -0
- package/dist/mirror.js +73 -0
- package/dist/mod.d.ts +30 -0
- package/dist/mod.js +7 -0
- package/dist/placeholder.d.ts +27 -0
- package/dist/placeholder.js +69 -0
- package/dist/platform.d.ts +9 -0
- package/dist/platform.js +12 -0
- package/dist/plugin.d.ts +39 -0
- package/dist/plugin.js +1 -0
- package/package.json +86 -5
- package/readme.md +92 -0
- package/wasm/canvist_web.d.ts +358 -0
- package/wasm/canvist_web.js +1175 -0
- package/wasm/canvist_web_bg.wasm +0 -0
- package/wasm/canvist_web_bg.wasm.d.ts +59 -0
- package/wasm-collab/canvist_web.d.ts +391 -0
- package/wasm-collab/canvist_web.js +1343 -0
- package/wasm-collab/canvist_web_bg.wasm +0 -0
- package/wasm-collab/canvist_web_bg.wasm.d.ts +64 -0
- package/wasm-full/canvist_web.d.ts +358 -0
- package/wasm-full/canvist_web.js +1175 -0
- package/wasm-full/canvist_web_bg.wasm +0 -0
- package/wasm-full/canvist_web_bg.wasm.d.ts +59 -0
- package/README.md +0 -3
package/dist/editor.js
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import { core, starterKit } from "@canvist/headless/extensions";
|
|
2
|
+
import { WebEditor } from "../wasm/canvist_web.js";
|
|
3
|
+
import { FontWatcher } from "./font-watcher.js";
|
|
4
|
+
import { Input } from "./input.js";
|
|
5
|
+
import { load } from "./load.js";
|
|
6
|
+
import { CanvasMeasurer } from "./measure.js";
|
|
7
|
+
import { AccessibilityMirror } from "./mirror.js";
|
|
8
|
+
import { Placeholder } from "./placeholder.js";
|
|
9
|
+
import { detectPlatform } from "./platform.js";
|
|
10
|
+
/** How the caret blinks: on and off for this long each. */
|
|
11
|
+
const CARET_BLINK_MS = 530;
|
|
12
|
+
/** How many recent input-to-paint times {@link Editor.inputLatency} summarizes. */
|
|
13
|
+
const LATENCY_SAMPLES = 1024;
|
|
14
|
+
/** How close, in CSS px, a measured border width must be to a snapped one to count as snapped. */
|
|
15
|
+
const BORDER_SNAP_TOLERANCE = 1 / 128;
|
|
16
|
+
/**
|
|
17
|
+
* Create an editor that renders into `element`.
|
|
18
|
+
*
|
|
19
|
+
* The element becomes the editor: give it a width (the document wraps to
|
|
20
|
+
* its content box) and, for a scrolling editor, a height and
|
|
21
|
+
* `overflow: auto`. Without a height it grows with the document and the
|
|
22
|
+
* page scrolls. Don't put padding on it; pad a wrapper instead, as with
|
|
23
|
+
* server-rendered `.cv-doc`.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* const editor = await createEditor(element, {
|
|
27
|
+
* content: { format: "markdown", input: "# Hello" },
|
|
28
|
+
* });
|
|
29
|
+
* editor.commands.toggleBold();
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export async function createEditor(element, options = {}) {
|
|
33
|
+
await load(options.wasm);
|
|
34
|
+
return new Editor(element, options);
|
|
35
|
+
}
|
|
36
|
+
/** A canvas editor. Create one with {@link createEditor}. */
|
|
37
|
+
export class Editor {
|
|
38
|
+
/** Run a registered command; returns whether it applied. */
|
|
39
|
+
commands;
|
|
40
|
+
/** Whether a registered command would apply. Nothing changes. */
|
|
41
|
+
can;
|
|
42
|
+
/** Answer a registered query. */
|
|
43
|
+
query;
|
|
44
|
+
/** Each registered plugin's API, by name. */
|
|
45
|
+
plugins;
|
|
46
|
+
#element;
|
|
47
|
+
#canvas;
|
|
48
|
+
#spacer;
|
|
49
|
+
#wasm;
|
|
50
|
+
#measurer;
|
|
51
|
+
#fonts;
|
|
52
|
+
#extensions;
|
|
53
|
+
#abort = new AbortController();
|
|
54
|
+
#waits = new Set();
|
|
55
|
+
#resize;
|
|
56
|
+
#input;
|
|
57
|
+
#mirror;
|
|
58
|
+
#placeholder;
|
|
59
|
+
/** A ring of recent input-to-paint times. */
|
|
60
|
+
#latency = new Float64Array(LATENCY_SAMPLES);
|
|
61
|
+
#subscribers = new Set();
|
|
62
|
+
/** Server-rendered HTML shown in the editor's place until the canvas has painted with settled fonts. */
|
|
63
|
+
#server;
|
|
64
|
+
#latencySamples = 0;
|
|
65
|
+
#frame = 0;
|
|
66
|
+
#blink = 0;
|
|
67
|
+
#height = 0;
|
|
68
|
+
#width = 0;
|
|
69
|
+
#lastFrame;
|
|
70
|
+
#focused = false;
|
|
71
|
+
#editable = true;
|
|
72
|
+
/** @internal Use {@link createEditor}, which loads the WebAssembly module first. */
|
|
73
|
+
constructor(element, options, create = (...args) => new WebEditor(...args)) {
|
|
74
|
+
const extensions = (options.extensions ?? starterKit);
|
|
75
|
+
this.#extensions = [...core, ...extensions];
|
|
76
|
+
this.#element = element;
|
|
77
|
+
this.#canvas = document.createElement("canvas");
|
|
78
|
+
this.#spacer = document.createElement("div");
|
|
79
|
+
this.#measurer = new CanvasMeasurer(element.ownerDocument);
|
|
80
|
+
this.#server = element.querySelector(":scope > .cv-doc");
|
|
81
|
+
this.#prepareDom();
|
|
82
|
+
this.#width = contentWidth(element);
|
|
83
|
+
this.#wasm = create(this.#canvas, this.#measurer, extensions.map((extension) => extension.name), wasmOptions(options));
|
|
84
|
+
this.#snapToDevice();
|
|
85
|
+
this.#fonts = new FontWatcher(element.ownerDocument.fonts, (families) => this.#fontsLoaded(families));
|
|
86
|
+
this.#measurer.listen((used) => this.#fonts.request(used));
|
|
87
|
+
if (options.content !== undefined) {
|
|
88
|
+
this.#load(options.content);
|
|
89
|
+
}
|
|
90
|
+
else if (this.#server !== null) {
|
|
91
|
+
this.#wasm.load("html", this.#server.outerHTML);
|
|
92
|
+
}
|
|
93
|
+
const commands = registeredNames(this.#extensions, "commands");
|
|
94
|
+
const queries = registeredNames(this.#extensions, "queries");
|
|
95
|
+
this.commands = calls(commands, (name, args) => this.#runEncoded(name, args));
|
|
96
|
+
this.can = calls(commands, (name, args) => this.#wasm.can(name, encode(args)));
|
|
97
|
+
this.query = calls(queries, (name, args) => JSON.parse(this.#wasm.query(name, encode(args))));
|
|
98
|
+
this.plugins = this.#setupPlugins(options.plugins ?? []);
|
|
99
|
+
const label = options.label ?? "Editor";
|
|
100
|
+
this.#mirror = new AccessibilityMirror(element, label, () => this.#wasm.html()[0] ?? "");
|
|
101
|
+
this.#input = new Input({
|
|
102
|
+
element,
|
|
103
|
+
canvas: this.#canvas,
|
|
104
|
+
wasm: this.#wasm,
|
|
105
|
+
signal: this.#abort.signal,
|
|
106
|
+
label,
|
|
107
|
+
viewportTop: () => this.#viewportTop(),
|
|
108
|
+
changed: (since) => this.#inputChanged(since),
|
|
109
|
+
focusChanged: (focused) => this.setFocused(focused),
|
|
110
|
+
}, options.input ?? "auto");
|
|
111
|
+
const [font = "", color = ""] = this.#wasm.bodyStyle();
|
|
112
|
+
this.#placeholder = new Placeholder(element, font, color);
|
|
113
|
+
this.setPlaceholder(options.placeholder ?? "");
|
|
114
|
+
this.setEditable(options.editable ?? true);
|
|
115
|
+
this.#resize = new ResizeObserver((entries) => this.#observe(entries));
|
|
116
|
+
this.#resize.observe(element, { box: "content-box" });
|
|
117
|
+
this.#listenForViewportChanges();
|
|
118
|
+
this.#watchScale();
|
|
119
|
+
this.#syncSize();
|
|
120
|
+
this.#render();
|
|
121
|
+
if (this.#server !== null) {
|
|
122
|
+
void this.ready();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** The registered extensions, including the ones every editor has. */
|
|
126
|
+
get extensions() {
|
|
127
|
+
return this.#extensions;
|
|
128
|
+
}
|
|
129
|
+
/** The canvas the editor paints. */
|
|
130
|
+
get canvas() {
|
|
131
|
+
return this.#canvas;
|
|
132
|
+
}
|
|
133
|
+
/** What the last frame did. */
|
|
134
|
+
get lastFrame() {
|
|
135
|
+
return this.#lastFrame;
|
|
136
|
+
}
|
|
137
|
+
/** How text input arrives in this browser: through `EditContext` or a hidden textarea. */
|
|
138
|
+
get inputMode() {
|
|
139
|
+
return this.#input.mode;
|
|
140
|
+
}
|
|
141
|
+
/** Focus the editor without scrolling. */
|
|
142
|
+
focus() {
|
|
143
|
+
this.#input.focus();
|
|
144
|
+
}
|
|
145
|
+
/** Whether the editor has focus. */
|
|
146
|
+
get focused() {
|
|
147
|
+
return this.#focused;
|
|
148
|
+
}
|
|
149
|
+
/** Whether people can change the document; see {@link EditorOptions.editable}. */
|
|
150
|
+
get editable() {
|
|
151
|
+
return this.#editable;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Let people change the document, or make it read-only. A read-only
|
|
155
|
+
* editor shows no caret, and subscribers hear of the change.
|
|
156
|
+
*/
|
|
157
|
+
setEditable(editable) {
|
|
158
|
+
if (editable === this.#editable) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.#editable = editable;
|
|
162
|
+
this.#input.setEditable(editable);
|
|
163
|
+
this.#showCaret();
|
|
164
|
+
this.#notify();
|
|
165
|
+
}
|
|
166
|
+
/** Show `text` while the document is empty; an empty string shows none. */
|
|
167
|
+
setPlaceholder(text) {
|
|
168
|
+
this.#placeholder.text = text;
|
|
169
|
+
this.#input.setPlaceholder(text);
|
|
170
|
+
this.#invalidate();
|
|
171
|
+
}
|
|
172
|
+
/** Whether the document is a single empty text block, as a new one is. */
|
|
173
|
+
get isEmpty() {
|
|
174
|
+
return this.#wasm.isEmpty();
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The text of the text block holding the selection's head, or `null`
|
|
178
|
+
* when the head is not in a text block. A floating menu shows on an
|
|
179
|
+
* empty one.
|
|
180
|
+
*/
|
|
181
|
+
blockText() {
|
|
182
|
+
return this.#wasm.editContextState()[0] ?? null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Input-to-paint times over the most recent input events: from each
|
|
186
|
+
* keystroke, composition, paste, drop, or pointer event's timestamp
|
|
187
|
+
* until the editor painted the change.
|
|
188
|
+
*/
|
|
189
|
+
inputLatency() {
|
|
190
|
+
const count = Math.min(this.#latencySamples, LATENCY_SAMPLES);
|
|
191
|
+
const sorted = this.#latency.slice(0, count).sort();
|
|
192
|
+
const at = (share) => sorted[Math.min(count - 1, Math.floor(share * count))] ?? 0;
|
|
193
|
+
return { samples: count, p50: at(0.5), p95: at(0.95), max: at(1) };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Resolve once fonts the document uses have loaded (or failed), plugins
|
|
197
|
+
* have finished their setup work, and the result is painted.
|
|
198
|
+
*/
|
|
199
|
+
async ready() {
|
|
200
|
+
for (;;) {
|
|
201
|
+
await this.#fonts.settled();
|
|
202
|
+
await Promise.allSettled([...this.#waits]);
|
|
203
|
+
if (!this.#fonts.loading && this.#waits.size === 0) {
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Paint in an animation frame, before the browser measures layout for
|
|
208
|
+
// ResizeObserver. Fonts can finish loading while observers are being
|
|
209
|
+
// notified, and resizing the canvas then is a ResizeObserver loop.
|
|
210
|
+
await nextFrame();
|
|
211
|
+
this.#hydrate();
|
|
212
|
+
this.#render();
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Call `listener` after every change to the document, the selection,
|
|
216
|
+
* focus, or {@link Editor.editable}, from input, commands, or the API.
|
|
217
|
+
* Returns a function that unsubscribes. Its shape suits React's
|
|
218
|
+
* `useSyncExternalStore`.
|
|
219
|
+
*/
|
|
220
|
+
subscribe(listener) {
|
|
221
|
+
this.#subscribers.add(listener);
|
|
222
|
+
return () => this.#subscribers.delete(listener);
|
|
223
|
+
}
|
|
224
|
+
/** Whether server-rendered HTML still stands in for the canvas. */
|
|
225
|
+
get hydrating() {
|
|
226
|
+
return this.#server !== null;
|
|
227
|
+
}
|
|
228
|
+
/** Run the command named `name`, for callers that pick commands at runtime. */
|
|
229
|
+
run(name, ...args) {
|
|
230
|
+
return this.#runEncoded(name, args[0]);
|
|
231
|
+
}
|
|
232
|
+
/** Answer the query named `name`, for callers that pick queries at runtime. */
|
|
233
|
+
ask(name, ...args) {
|
|
234
|
+
return JSON.parse(this.#wasm.query(name, encode(args[0])));
|
|
235
|
+
}
|
|
236
|
+
/** Replace the document. */
|
|
237
|
+
setContent(content) {
|
|
238
|
+
this.#load(content);
|
|
239
|
+
this.#edited();
|
|
240
|
+
}
|
|
241
|
+
/** The document as `format`. */
|
|
242
|
+
export(format) {
|
|
243
|
+
return this.#wasm.export(format);
|
|
244
|
+
}
|
|
245
|
+
/** The document as ProseMirror JSON. */
|
|
246
|
+
toJson() {
|
|
247
|
+
return JSON.parse(this.#wasm.export("json"));
|
|
248
|
+
}
|
|
249
|
+
/** Server-rendered HTML for the document at the editor's width, and its stylesheet. */
|
|
250
|
+
toHtml() {
|
|
251
|
+
const [html = "", css = ""] = this.#wasm.html();
|
|
252
|
+
return { html, css };
|
|
253
|
+
}
|
|
254
|
+
/** The whole document as SVG, painted from the same display list as the canvas. */
|
|
255
|
+
toSvg() {
|
|
256
|
+
return this.#wasm.svg();
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Where layout put every block, line, caret stop, and text run, in
|
|
260
|
+
* document coordinates: what the parity tests compare with the DOM.
|
|
261
|
+
*/
|
|
262
|
+
geometry() {
|
|
263
|
+
return JSON.parse(this.#wasm.geometry());
|
|
264
|
+
}
|
|
265
|
+
/** The text selection, or `null` for a block selection. */
|
|
266
|
+
get selection() {
|
|
267
|
+
return JSON.parse(this.#wasm.selection());
|
|
268
|
+
}
|
|
269
|
+
/** Select from `anchor` to `head`, or place a caret at `anchor`. Offsets count UTF-16 code units. */
|
|
270
|
+
select(anchor, head = anchor) {
|
|
271
|
+
this.#wasm.select(Uint32Array.from(anchor.path), anchor.offset, Uint32Array.from(head.path), head.offset);
|
|
272
|
+
this.#edited();
|
|
273
|
+
}
|
|
274
|
+
/** Replace the selection with `text`, as typing does (input rules apply). */
|
|
275
|
+
insertText(text) {
|
|
276
|
+
const changed = this.#wasm.insertText(text);
|
|
277
|
+
this.#edited();
|
|
278
|
+
return changed;
|
|
279
|
+
}
|
|
280
|
+
/** Undo the last change. */
|
|
281
|
+
undo() {
|
|
282
|
+
const changed = this.#wasm.undo();
|
|
283
|
+
this.#edited();
|
|
284
|
+
return changed;
|
|
285
|
+
}
|
|
286
|
+
/** Redo the last undone change. */
|
|
287
|
+
redo() {
|
|
288
|
+
const changed = this.#wasm.redo();
|
|
289
|
+
this.#edited();
|
|
290
|
+
return changed;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Show the caret and blink it, as when the editor has focus. Subscribers
|
|
294
|
+
* hear of the change.
|
|
295
|
+
*/
|
|
296
|
+
setFocused(focused) {
|
|
297
|
+
if (focused === this.#focused) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
this.#focused = focused;
|
|
301
|
+
this.#showCaret();
|
|
302
|
+
this.#notify();
|
|
303
|
+
}
|
|
304
|
+
/** The caret's rectangle in document coordinates, or `null` when the selection is a range. */
|
|
305
|
+
caretRect() {
|
|
306
|
+
this.#settle();
|
|
307
|
+
return domRect(this.#wasm.caretRect());
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The rectangle of a caret at `position` in document coordinates, or
|
|
311
|
+
* `null` when the document has no such position or it is scrolled too far
|
|
312
|
+
* away to be laid out. Collaborators' carets are drawn with it: positions
|
|
313
|
+
* from another peer may not exist here yet.
|
|
314
|
+
*/
|
|
315
|
+
caretRectAt(position) {
|
|
316
|
+
this.#settle();
|
|
317
|
+
return domRect(this.#wasm.caretRectAt(Uint32Array.from(position.path), position.offset));
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The smallest rectangle around the selection in document coordinates:
|
|
321
|
+
* the caret's for a caret, or `null` while the selection is scrolled too
|
|
322
|
+
* far away to be laid out. Pass it to {@link Editor.toClientRect} to
|
|
323
|
+
* anchor a menu, or use `@canvist/web/menus`.
|
|
324
|
+
*/
|
|
325
|
+
selectionRect() {
|
|
326
|
+
this.#settle();
|
|
327
|
+
return domRect(this.#wasm.selectionBounds());
|
|
328
|
+
}
|
|
329
|
+
/** `rect`, in document coordinates, in the viewport's coordinates, as `getBoundingClientRect` reports them. */
|
|
330
|
+
toClientRect(rect) {
|
|
331
|
+
const canvas = this.#canvas.getBoundingClientRect();
|
|
332
|
+
return new DOMRect(canvas.left + rect.x, canvas.top + rect.y - this.#viewportTop(), rect.width, rect.height);
|
|
333
|
+
}
|
|
334
|
+
/** Measurement cache statistics. */
|
|
335
|
+
stats() {
|
|
336
|
+
const stats = JSON.parse(this.#wasm.stats());
|
|
337
|
+
const lookups = stats.hits + stats.misses;
|
|
338
|
+
return { ...stats, hitRate: lookups === 0 ? 1 : stats.hits / lookups };
|
|
339
|
+
}
|
|
340
|
+
/** The CSS `font` of every font the layout has used. */
|
|
341
|
+
fonts() {
|
|
342
|
+
return this.#wasm.fonts();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The document changed outside this editor's own calls, as a
|
|
346
|
+
* collaborator's change does: repaint, and tell subscribers.
|
|
347
|
+
*
|
|
348
|
+
* @internal Called by `@canvist/web/collab`.
|
|
349
|
+
*/
|
|
350
|
+
documentChanged() {
|
|
351
|
+
this.#edited();
|
|
352
|
+
}
|
|
353
|
+
/** Lay out and paint now instead of on the next animation frame. */
|
|
354
|
+
render() {
|
|
355
|
+
this.#render();
|
|
356
|
+
return this.#lastFrame;
|
|
357
|
+
}
|
|
358
|
+
/** Remove the editor's elements and listeners and release its memory. */
|
|
359
|
+
destroy() {
|
|
360
|
+
this.#abort.abort();
|
|
361
|
+
cancelAnimationFrame(this.#frame);
|
|
362
|
+
clearTimeout(this.#blink);
|
|
363
|
+
this.#resize.disconnect();
|
|
364
|
+
this.#fonts.dispose();
|
|
365
|
+
this.#mirror.dispose();
|
|
366
|
+
this.#placeholder.dispose();
|
|
367
|
+
this.#input.dispose();
|
|
368
|
+
this.#canvas.remove();
|
|
369
|
+
this.#spacer.remove();
|
|
370
|
+
this.#wasm.free();
|
|
371
|
+
}
|
|
372
|
+
[Symbol.dispose]() {
|
|
373
|
+
this.destroy();
|
|
374
|
+
}
|
|
375
|
+
#load(content) {
|
|
376
|
+
if ("format" in content) {
|
|
377
|
+
this.#wasm.load(content.format, content.input);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
this.#wasm.load("json", JSON.stringify(content));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
#prepareDom() {
|
|
384
|
+
const element = this.#element;
|
|
385
|
+
if (getComputedStyle(element).position === "static") {
|
|
386
|
+
element.style.position = "relative";
|
|
387
|
+
}
|
|
388
|
+
element.classList.add("cv-editor");
|
|
389
|
+
const canvas = this.#canvas;
|
|
390
|
+
canvas.className = "cv-canvas";
|
|
391
|
+
Object.assign(canvas.style, {
|
|
392
|
+
display: "block",
|
|
393
|
+
position: "sticky",
|
|
394
|
+
top: "0",
|
|
395
|
+
width: "100%",
|
|
396
|
+
height: "0px",
|
|
397
|
+
});
|
|
398
|
+
this.#spacer.className = "cv-spacer";
|
|
399
|
+
this.#spacer.setAttribute("aria-hidden", "true");
|
|
400
|
+
element.append(canvas, this.#spacer);
|
|
401
|
+
// Until fonts settle, the server's HTML holds the space the canvas
|
|
402
|
+
// will take; both laid out at once would double the height.
|
|
403
|
+
if (this.#server !== null) {
|
|
404
|
+
canvas.style.display = "none";
|
|
405
|
+
this.#spacer.style.display = "none";
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
#setupPlugins(plugins) {
|
|
409
|
+
const context = {
|
|
410
|
+
element: this.#element,
|
|
411
|
+
measurer: this.#measurer,
|
|
412
|
+
signal: this.#abort.signal,
|
|
413
|
+
fontsLoaded: (families) => this.#fontsLoaded(families),
|
|
414
|
+
hideFamilies: (families) => {
|
|
415
|
+
this.#wasm.hideFamilies([...families]);
|
|
416
|
+
this.#invalidate();
|
|
417
|
+
},
|
|
418
|
+
invalidate: () => this.#invalidate(),
|
|
419
|
+
waitFor: (work) => {
|
|
420
|
+
const tracked = work.finally(() => this.#waits.delete(tracked));
|
|
421
|
+
this.#waits.add(tracked);
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
const apis = {};
|
|
425
|
+
for (const plugin of plugins) {
|
|
426
|
+
apis[plugin.name] = plugin.setup(context);
|
|
427
|
+
}
|
|
428
|
+
return apis;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* A family's faces load one subset, weight, or style at a time, so the
|
|
432
|
+
* same family can arrive more than once; each arrival can change
|
|
433
|
+
* advances, so each forgets that family's measurements again.
|
|
434
|
+
*/
|
|
435
|
+
#fontsLoaded(families) {
|
|
436
|
+
this.#measurer.forget();
|
|
437
|
+
if (families.length > 0 && this.#wasm.fontsLoaded([...families]) > 0) {
|
|
438
|
+
this.#invalidate();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** Run the command named `name` with `args` and repaint on the next frame. */
|
|
442
|
+
#runEncoded(name, args) {
|
|
443
|
+
const applied = this.#wasm.run(name, encode(args));
|
|
444
|
+
this.#edited();
|
|
445
|
+
return applied;
|
|
446
|
+
}
|
|
447
|
+
/** Repaint on the next frame whenever the document scrolls or the window resizes. */
|
|
448
|
+
#listenForViewportChanges() {
|
|
449
|
+
const signal = this.#abort.signal;
|
|
450
|
+
this.#scroller().addEventListener("scroll", () => {
|
|
451
|
+
this.#invalidate();
|
|
452
|
+
if (this.#focused) {
|
|
453
|
+
this.#input.syncBounds();
|
|
454
|
+
}
|
|
455
|
+
}, { passive: true, signal });
|
|
456
|
+
window.addEventListener("resize", () => this.#invalidate(), { signal });
|
|
457
|
+
}
|
|
458
|
+
/** The document or selection changed outside input events. */
|
|
459
|
+
#edited() {
|
|
460
|
+
this.#invalidate();
|
|
461
|
+
this.#input.sync();
|
|
462
|
+
this.#mirror.schedule();
|
|
463
|
+
this.#notify();
|
|
464
|
+
}
|
|
465
|
+
#notify() {
|
|
466
|
+
for (const listener of this.#subscribers) {
|
|
467
|
+
listener();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Swap server-rendered HTML for the canvas in one task, so no frame
|
|
472
|
+
* shows both or neither. The canvas was laid out with the same fonts and
|
|
473
|
+
* the same measurements as the HTML, so nothing moves.
|
|
474
|
+
*/
|
|
475
|
+
#hydrate() {
|
|
476
|
+
const server = this.#server;
|
|
477
|
+
// A destroyed editor leaves the HTML to whichever editor replaces it.
|
|
478
|
+
if (server === null || this.#abort.signal.aborted) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
this.#server = null;
|
|
482
|
+
server.remove();
|
|
483
|
+
this.#canvas.style.display = "block";
|
|
484
|
+
this.#spacer.style.display = "";
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* An input event changed the document or selection: paint in the same
|
|
488
|
+
* task, so the change reaches the screen in the next frame, and record
|
|
489
|
+
* how long that took.
|
|
490
|
+
*/
|
|
491
|
+
#inputChanged(since) {
|
|
492
|
+
this.#wasm.setCaretOn(true);
|
|
493
|
+
this.#restartBlink();
|
|
494
|
+
this.#render();
|
|
495
|
+
this.#revealCaret();
|
|
496
|
+
this.#mirror.schedule();
|
|
497
|
+
this.#latency[this.#latencySamples % LATENCY_SAMPLES] = performance.now() -
|
|
498
|
+
since;
|
|
499
|
+
this.#latencySamples += 1;
|
|
500
|
+
this.#notify();
|
|
501
|
+
}
|
|
502
|
+
/** Scroll the caret into view. */
|
|
503
|
+
#revealCaret() {
|
|
504
|
+
const caret = this.caretRect();
|
|
505
|
+
if (caret === null) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const scroller = this.#scroller();
|
|
509
|
+
if (scroller instanceof HTMLElement) {
|
|
510
|
+
const top = scroller.scrollTop;
|
|
511
|
+
const bottom = top + scroller.clientHeight;
|
|
512
|
+
const delta = caret.top < top
|
|
513
|
+
? caret.top - top
|
|
514
|
+
: Math.max(0, caret.bottom - bottom);
|
|
515
|
+
scroller.scrollTop += delta;
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const element = this.#element.getBoundingClientRect();
|
|
519
|
+
const top = element.top + this.#element.clientTop + caret.top;
|
|
520
|
+
const bottom = top + caret.height;
|
|
521
|
+
const delta = top < 0 ? top : Math.max(0, bottom - window.innerHeight);
|
|
522
|
+
if (delta !== 0) {
|
|
523
|
+
window.scrollBy(0, delta);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Lay out now if a change is waiting for the next frame, so geometry read
|
|
528
|
+
* right after an edit describes the edited document.
|
|
529
|
+
*/
|
|
530
|
+
#settle() {
|
|
531
|
+
if (this.#frame !== 0) {
|
|
532
|
+
this.#render();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
#invalidate() {
|
|
536
|
+
if (this.#frame !== 0) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
this.#frame = requestAnimationFrame(() => this.#render());
|
|
540
|
+
}
|
|
541
|
+
#render() {
|
|
542
|
+
cancelAnimationFrame(this.#frame);
|
|
543
|
+
this.#frame = 0;
|
|
544
|
+
// The canvas is as tall as the visible part of the document, which
|
|
545
|
+
// depends on the document's height, which the frame measures. A
|
|
546
|
+
// second pass settles a height change in the same frame.
|
|
547
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
548
|
+
const changed = this.#syncSize();
|
|
549
|
+
this.#wasm.setTop(this.#viewportTop());
|
|
550
|
+
const [height = 0, shift = 0, laidOut = 0, measured = 0, drawn = 0, full = 0, painted = 0,] = this.#wasm.frame();
|
|
551
|
+
this.#lastFrame = {
|
|
552
|
+
height,
|
|
553
|
+
laidOut,
|
|
554
|
+
measured,
|
|
555
|
+
drawn,
|
|
556
|
+
full: full === 1,
|
|
557
|
+
painted: painted === 1,
|
|
558
|
+
};
|
|
559
|
+
const heightChanged = height !== this.#height;
|
|
560
|
+
this.#height = height;
|
|
561
|
+
if (shift !== 0) {
|
|
562
|
+
this.#scroller().scrollBy(0, shift);
|
|
563
|
+
}
|
|
564
|
+
if (!heightChanged && !changed) {
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
this.#placeholder.show(this.#server === null && this.#wasm.isEmpty()
|
|
569
|
+
? this.selectionRect()
|
|
570
|
+
: null);
|
|
571
|
+
}
|
|
572
|
+
/** Size the canvas to the visible part of the document. Returns whether it changed. */
|
|
573
|
+
#syncSize() {
|
|
574
|
+
const visible = this.#visibleHeight();
|
|
575
|
+
const height = Math.max(0, Math.min(this.#height, visible));
|
|
576
|
+
const changed = Number.parseFloat(this.#canvas.style.height) !== height;
|
|
577
|
+
const scale = window.devicePixelRatio;
|
|
578
|
+
this.#spacer.style.height = `${Math.max(0, this.#height - height)}px`;
|
|
579
|
+
if (changed) {
|
|
580
|
+
this.#canvas.style.height = `${height}px`;
|
|
581
|
+
}
|
|
582
|
+
// Resizing to the same size is free: the editor compares first.
|
|
583
|
+
const box = this.#canvas.getBoundingClientRect();
|
|
584
|
+
this.#wasm.resize(this.#width, height, scale, devicePixels(box.left, box.width, scale), devicePixels(box.top, box.height, scale));
|
|
585
|
+
return changed;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Record new sizes and paint on the next frame. Resizing the canvas here
|
|
589
|
+
* would change an observed box inside the observer's own callback,
|
|
590
|
+
* which browsers report as a ResizeObserver loop.
|
|
591
|
+
*/
|
|
592
|
+
#observe(entries) {
|
|
593
|
+
for (const entry of entries) {
|
|
594
|
+
if (entry.target === this.#element) {
|
|
595
|
+
this.#elementResized(entry);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
this.#invalidate();
|
|
599
|
+
}
|
|
600
|
+
/** Wrap to the element's new content width. */
|
|
601
|
+
#elementResized(entry) {
|
|
602
|
+
this.#width = entry.contentBoxSize[0]?.inlineSize ??
|
|
603
|
+
entry.contentRect.width;
|
|
604
|
+
}
|
|
605
|
+
/** Snap to the pixel grid and border widths this browser uses at the current ratio. */
|
|
606
|
+
#snapToDevice() {
|
|
607
|
+
const document = this.#element.ownerDocument;
|
|
608
|
+
this.#wasm.setPixelGrid(pixelGrid(document, navigator.userAgent));
|
|
609
|
+
this.#wasm.setBorderScale(borderScale(document) ?? 0);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Measure again when the device pixel ratio changes (browser zoom, or a
|
|
613
|
+
* window moving to another screen): browsers round line metrics to
|
|
614
|
+
* device pixels, so line positions move with it.
|
|
615
|
+
*/
|
|
616
|
+
#watchScale() {
|
|
617
|
+
const query = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
|
618
|
+
query.addEventListener("change", () => {
|
|
619
|
+
this.#measurer.forget();
|
|
620
|
+
this.#wasm.forgetMeasurements();
|
|
621
|
+
this.#snapToDevice();
|
|
622
|
+
this.#invalidate();
|
|
623
|
+
this.#watchScale();
|
|
624
|
+
}, { once: true, signal: this.#abort.signal });
|
|
625
|
+
}
|
|
626
|
+
/** The element whose scrolling moves the document: the editor itself, or the page. */
|
|
627
|
+
#scroller() {
|
|
628
|
+
const overflow = getComputedStyle(this.#element).overflowY;
|
|
629
|
+
return overflow === "auto" || overflow === "scroll"
|
|
630
|
+
? this.#element
|
|
631
|
+
: window;
|
|
632
|
+
}
|
|
633
|
+
#visibleHeight() {
|
|
634
|
+
const scroller = this.#scroller();
|
|
635
|
+
return scroller === window
|
|
636
|
+
? window.innerHeight
|
|
637
|
+
: this.#element.clientHeight;
|
|
638
|
+
}
|
|
639
|
+
/** The document coordinate at the canvas's top edge. */
|
|
640
|
+
#viewportTop() {
|
|
641
|
+
const canvas = this.#canvas.getBoundingClientRect();
|
|
642
|
+
const element = this.#element.getBoundingClientRect();
|
|
643
|
+
const scrolled = this.#scroller() === window ? 0 : this.#element.scrollTop;
|
|
644
|
+
return Math.max(0, canvas.top - element.top - this.#element.clientTop + scrolled);
|
|
645
|
+
}
|
|
646
|
+
/** Draw the caret while the editor has focus and accepts typing. */
|
|
647
|
+
#showCaret() {
|
|
648
|
+
this.#wasm.setFocused(this.#focused && this.#editable);
|
|
649
|
+
this.#restartBlink();
|
|
650
|
+
this.#invalidate();
|
|
651
|
+
}
|
|
652
|
+
#restartBlink() {
|
|
653
|
+
clearTimeout(this.#blink);
|
|
654
|
+
if (!this.#focused || !this.#editable) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
let on = true;
|
|
658
|
+
const tick = () => {
|
|
659
|
+
on = !on;
|
|
660
|
+
this.#wasm.setCaretOn(on);
|
|
661
|
+
this.#invalidate();
|
|
662
|
+
this.#blink = window.setTimeout(tick, CARET_BLINK_MS);
|
|
663
|
+
};
|
|
664
|
+
this.#blink = window.setTimeout(tick, CARET_BLINK_MS);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* The grid this browser snaps DOM text baselines and backgrounds to.
|
|
669
|
+
*
|
|
670
|
+
* Firefox and WebKit snap to device pixels. Chromium does too when it lays
|
|
671
|
+
* out in device units, as it does on real high-density screens; when it
|
|
672
|
+
* lays out in CSS units (as under device emulation), it snaps to whole CSS
|
|
673
|
+
* pixels and scales. Layout in device units can represent a box 1/64 of a
|
|
674
|
+
* device pixel wide, which CSS-unit layout rounds to zero.
|
|
675
|
+
*/
|
|
676
|
+
export function pixelGrid(document, userAgent) {
|
|
677
|
+
const scale = window.devicePixelRatio;
|
|
678
|
+
if (scale === 1 || detectBreaks(userAgent) !== "chromium") {
|
|
679
|
+
return "device";
|
|
680
|
+
}
|
|
681
|
+
const probe = document.createElement("div");
|
|
682
|
+
probe.style.cssText = `position:absolute;left:0;top:0;height:1px;width:${1 / (64 * scale)}px;visibility:hidden`;
|
|
683
|
+
document.body.append(probe);
|
|
684
|
+
const width = probe.getBoundingClientRect().width;
|
|
685
|
+
probe.remove();
|
|
686
|
+
return width > 0 ? "device" : "css";
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* How many units per CSS pixel this browser snaps border widths down to: the
|
|
690
|
+
* device pixel ratio, 1 when it lays out in CSS pixels, or `null` when it
|
|
691
|
+
* keeps exact widths. A 1.7 px border tells them apart.
|
|
692
|
+
*/
|
|
693
|
+
export function borderScale(document) {
|
|
694
|
+
const width = 1.7;
|
|
695
|
+
const outer = document.createElement("div");
|
|
696
|
+
outer.style.cssText =
|
|
697
|
+
`position:absolute;left:0;top:0;visibility:hidden;border-left:${width}px solid;width:10px;height:1px`;
|
|
698
|
+
const inner = document.createElement("div");
|
|
699
|
+
inner.style.cssText = "width:1px;height:1px";
|
|
700
|
+
outer.append(inner);
|
|
701
|
+
document.body.append(outer);
|
|
702
|
+
const used = inner.getBoundingClientRect().left -
|
|
703
|
+
outer.getBoundingClientRect().left;
|
|
704
|
+
outer.remove();
|
|
705
|
+
for (const scale of [window.devicePixelRatio, 1]) {
|
|
706
|
+
if (Math.abs(used - Math.max(1, Math.floor(width * scale)) / scale) <
|
|
707
|
+
BORDER_SNAP_TOLERANCE) {
|
|
708
|
+
return scale;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* How many device pixels a box spanning `size` CSS px from `start` covers.
|
|
715
|
+
* Browsers snap a box's edges to device pixels, not its size, so at
|
|
716
|
+
* fractional ratios this can differ from `round(size * scale)` by one.
|
|
717
|
+
* Computed here rather than observed with `device-pixel-content-box`,
|
|
718
|
+
* which Firefox and Safari don't support, so the size is exact in every
|
|
719
|
+
* engine and only the element needs observing.
|
|
720
|
+
*/
|
|
721
|
+
function devicePixels(start, size, scale) {
|
|
722
|
+
return Math.max(0, Math.round((start + size) * scale) - Math.round(start * scale));
|
|
723
|
+
}
|
|
724
|
+
/** `[x, y, width, height]` as a rectangle, or `null` when empty. */
|
|
725
|
+
function domRect(rect) {
|
|
726
|
+
const [x, y, width, height] = rect;
|
|
727
|
+
return x === undefined || y === undefined || width === undefined ||
|
|
728
|
+
height === undefined
|
|
729
|
+
? null
|
|
730
|
+
: new DOMRect(x, y, width, height);
|
|
731
|
+
}
|
|
732
|
+
/** Resolve in the next animation frame. */
|
|
733
|
+
function nextFrame() {
|
|
734
|
+
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
735
|
+
}
|
|
736
|
+
/** The names of everything `extensions` register as `kind`: their commands or their queries. */
|
|
737
|
+
function registeredNames(extensions, kind) {
|
|
738
|
+
return extensions.flatMap((extension) => extension[kind].map((spec) => spec.name));
|
|
739
|
+
}
|
|
740
|
+
/** The JSON options `WebEditor` reads, with each default filled in. */
|
|
741
|
+
function wasmOptions(options) {
|
|
742
|
+
return JSON.stringify({
|
|
743
|
+
client: options.client ?? 1,
|
|
744
|
+
breaks: options.breaks ?? detectBreaks(navigator.userAgent),
|
|
745
|
+
overscan: options.overscan ?? window.innerHeight,
|
|
746
|
+
platform: options.platform ?? detectPlatform(navigator),
|
|
747
|
+
theme: options.theme ?? {},
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
/** The width of `element`'s content box, with its fraction. */
|
|
751
|
+
function contentWidth(element) {
|
|
752
|
+
const style = getComputedStyle(element);
|
|
753
|
+
const insets = [
|
|
754
|
+
"borderLeftWidth",
|
|
755
|
+
"borderRightWidth",
|
|
756
|
+
"paddingLeft",
|
|
757
|
+
"paddingRight",
|
|
758
|
+
];
|
|
759
|
+
return insets.reduce((width, inset) => width - Number.parseFloat(style[inset]), element.getBoundingClientRect().width);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* The line-breaking tailoring of the browser running this code. CSS leaves
|
|
763
|
+
* the details to each engine and there is no feature test for them, so this
|
|
764
|
+
* reads the user agent.
|
|
765
|
+
*/
|
|
766
|
+
export function detectBreaks(userAgent) {
|
|
767
|
+
return /(Chrome|Chromium|Edg)\//.test(userAgent) ? "chromium" : "icu";
|
|
768
|
+
}
|
|
769
|
+
/** An object with one method per name, each calling `invoke`; see `@canvist/headless`. */
|
|
770
|
+
function calls(names, invoke) {
|
|
771
|
+
const methods = Object.fromEntries(names.map((name) => [name, (args) => invoke(name, args)]));
|
|
772
|
+
return methods;
|
|
773
|
+
}
|
|
774
|
+
function encode(args) {
|
|
775
|
+
return args === undefined ? "null" : JSON.stringify(args);
|
|
776
|
+
}
|