@canvist/web 0.0.0 → 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.
Files changed (47) hide show
  1. package/dist/collab.d.ts +99 -0
  2. package/dist/collab.js +211 -0
  3. package/dist/edit-context.d.ts +59 -0
  4. package/dist/edit-context.js +16 -0
  5. package/dist/editor.d.ts +301 -0
  6. package/dist/editor.js +776 -0
  7. package/dist/font-watcher.d.ts +28 -0
  8. package/dist/font-watcher.js +88 -0
  9. package/dist/fonts.d.ts +130 -0
  10. package/dist/fonts.js +343 -0
  11. package/dist/full.d.ts +40 -0
  12. package/dist/full.js +25 -0
  13. package/dist/input.d.ts +68 -0
  14. package/dist/input.js +551 -0
  15. package/dist/load.d.ts +9 -0
  16. package/dist/load.js +21 -0
  17. package/dist/measure.d.ts +56 -0
  18. package/dist/measure.js +524 -0
  19. package/dist/menus.d.ts +71 -0
  20. package/dist/menus.js +173 -0
  21. package/dist/mirror.d.ts +24 -0
  22. package/dist/mirror.js +73 -0
  23. package/dist/mod.d.ts +30 -0
  24. package/dist/mod.js +7 -0
  25. package/dist/placeholder.d.ts +27 -0
  26. package/dist/placeholder.js +69 -0
  27. package/dist/platform.d.ts +9 -0
  28. package/dist/platform.js +12 -0
  29. package/dist/plugin.d.ts +39 -0
  30. package/dist/plugin.js +1 -0
  31. package/package.json +87 -1
  32. package/readme.md +92 -0
  33. package/wasm/canvist_web.d.ts +358 -0
  34. package/wasm/canvist_web.js +1175 -0
  35. package/wasm/canvist_web_bg.wasm +0 -0
  36. package/wasm/canvist_web_bg.wasm.d.ts +59 -0
  37. package/wasm-collab/canvist_web.d.ts +391 -0
  38. package/wasm-collab/canvist_web.js +1343 -0
  39. package/wasm-collab/canvist_web_bg.wasm +0 -0
  40. package/wasm-collab/canvist_web_bg.wasm.d.ts +64 -0
  41. package/wasm-full/canvist_web.d.ts +358 -0
  42. package/wasm-full/canvist_web.js +1175 -0
  43. package/wasm-full/canvist_web_bg.wasm +0 -0
  44. package/wasm-full/canvist_web_bg.wasm.d.ts +59 -0
  45. package/CHANGELOG.md +0 -3
  46. package/LICENSE +0 -24
  47. package/README.md +0 -3
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Text measurement with the browser's own text engine.
3
+ *
4
+ * Layout asks for every segment it is missing in one batch per relayout,
5
+ * and caches the answers by (font, segment). This side measures a batch
6
+ * with `measureText` on a canvas whose text properties match the painting
7
+ * canvas and the CSS that server-rendered HTML declares, so a word is the
8
+ * same width in all three.
9
+ *
10
+ * @module
11
+ */
12
+ import type { HostMeasurer } from "../wasm/canvist_web.js";
13
+ /** Called after each batch with the fonts it used and the text in each. */
14
+ export type MeasureListener = (used: ReadonlyMap<string, string>) => void;
15
+ /**
16
+ * The browser half of layout's `TextMeasurer`.
17
+ *
18
+ * Per-character advances come from measuring prefixes that end on grapheme
19
+ * boundaries, so they sum to exactly the width `fillText` paints for the
20
+ * whole segment (kerning and ligatures included), and caret stops fall
21
+ * where the browser puts them. Each grapheme's advance goes to its first
22
+ * scalar value; the rest of the cluster advances nothing.
23
+ */
24
+ export declare class CanvasMeasurer implements HostMeasurer {
25
+ #private;
26
+ constructor(document?: Document);
27
+ /** Segments measured so far. */
28
+ get measured(): number;
29
+ /** Batches measured so far. */
30
+ get calls(): number;
31
+ /** Hear about the fonts and text each batch measured, e.g. to load web fonts for that text. */
32
+ listen(listener: MeasureListener): () => void;
33
+ /** Forget cached widths and resolved faces, which change when a web font finishes loading. */
34
+ forget(): void;
35
+ paintFont(id: number): string;
36
+ addFont(id: number, css: string): void;
37
+ measure(metrics: Uint32Array, lineFonts: Uint32Array, lineHeights: Float32Array, fonts: Uint32Array, text: string, lengths: Uint32Array): Float32Array;
38
+ }
39
+ /**
40
+ * A sequence a face draws as one glyph. Browsers place caret stops inside
41
+ * a ligature at fractions of its width (in equal parts, unless the font
42
+ * says otherwise), not where each letter's own advance would end.
43
+ */
44
+ export interface Ligature {
45
+ readonly text: string;
46
+ /** Each inner caret edge, as a share of the ligature's width. */
47
+ readonly edges: readonly number[];
48
+ }
49
+ /**
50
+ * Move the edges inside each ligature in `clusters` to where the DOM puts
51
+ * them: at the ligature's fractions of the width between its outer edges,
52
+ * which stay where the canvas measured them.
53
+ */
54
+ export declare function splitLigatures(clusters: readonly string[], edges: number[], ligatures: readonly Ligature[]): void;
55
+ /** The families of a CSS `font-family` list, splitting on commas outside quotes. */
56
+ export declare function splitFamilies(list: string): string[];
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Code units below this are never part of a multi-character grapheme
3
+ * cluster: combining marks start at U+0300, and joiners, variation
4
+ * selectors, and surrogates are all above it.
5
+ */
6
+ const SIMPLE_TEXT_LIMIT = 0x300;
7
+ /** Scripts whose letters join: Arabic, Syriac, Thaana, N'Ko, Mandaic, and Mongolian. */
8
+ const JOINING = /[\u0600-\u07ff\u0800-\u08ff\u1800-\u18af\ufb50-\ufdff\ufe70-\ufeff]/u;
9
+ /** Text whose width tells which face a family list resolves to. */
10
+ const RESOLUTION_SAMPLE = "The quick brown fox jumps over the lazy dog 0123456789";
11
+ /** How far the DOM and canvas widths of the sample may differ and still be one face. */
12
+ const SAME_FACE_TOLERANCE = 0.05;
13
+ /** Letter sequences that fonts commonly draw as one ligature glyph, longest first. */
14
+ const LIGATURE_CANDIDATES = ["ffi", "ffl", "ff", "fi", "fl", "fj", "ft", "Th"];
15
+ /** The font size ligatures are probed at, in CSS px: large, so whole-pixel DOM rectangles barely round. */
16
+ const LIGATURE_PROBE_PX = 200;
17
+ /**
18
+ * How far, as a share of the sequence's width, the DOM's caret edge inside
19
+ * a sequence may sit from the canvas's before the sequence counts as a
20
+ * ligature. Ligatures move it by 5% or more; WebKit's rounding at the
21
+ * probe size moves it under 0.5%.
22
+ */
23
+ const LIGATURE_TOLERANCE = 0.02;
24
+ /**
25
+ * The browser half of layout's `TextMeasurer`.
26
+ *
27
+ * Per-character advances come from measuring prefixes that end on grapheme
28
+ * boundaries, so they sum to exactly the width `fillText` paints for the
29
+ * whole segment (kerning and ligatures included), and caret stops fall
30
+ * where the browser puts them. Each grapheme's advance goes to its first
31
+ * scalar value; the rest of the cluster advances nothing.
32
+ */
33
+ export class CanvasMeasurer {
34
+ #document;
35
+ #context;
36
+ #fonts = [];
37
+ #graphemes = new Intl.Segmenter(undefined, {
38
+ granularity: "grapheme",
39
+ });
40
+ #listeners = new Set();
41
+ /** Widths of single graphemes, by font and grapheme. */
42
+ #singles = new Map();
43
+ /** How each font id is drawn and measured, once resolved. */
44
+ #resolved = new Map();
45
+ #font = "";
46
+ #measured = 0;
47
+ #calls = 0;
48
+ constructor(document = globalThis.document) {
49
+ const context = document.createElement("canvas").getContext("2d");
50
+ if (context === null) {
51
+ throw new Error("canvist needs a 2D canvas context to measure text");
52
+ }
53
+ context.fontKerning = "normal";
54
+ context.fontVariantCaps = "normal";
55
+ context.textRendering = "auto";
56
+ context.letterSpacing = "0px";
57
+ context.wordSpacing = "0px";
58
+ this.#document = document;
59
+ this.#context = context;
60
+ }
61
+ /** Segments measured so far. */
62
+ get measured() {
63
+ return this.#measured;
64
+ }
65
+ /** Batches measured so far. */
66
+ get calls() {
67
+ return this.#calls;
68
+ }
69
+ /** Hear about the fonts and text each batch measured, e.g. to load web fonts for that text. */
70
+ listen(listener) {
71
+ this.#listeners.add(listener);
72
+ return () => this.#listeners.delete(listener);
73
+ }
74
+ /** Forget cached widths and resolved faces, which change when a web font finishes loading. */
75
+ forget() {
76
+ this.#singles.clear();
77
+ this.#resolved.clear();
78
+ this.#font = "";
79
+ }
80
+ paintFont(id) {
81
+ return this.#resolution(id).font;
82
+ }
83
+ #resolution(id) {
84
+ let resolution = this.#resolved.get(id);
85
+ if (resolution === undefined) {
86
+ resolution = this.#resolve(this.#css(id));
87
+ this.#resolved.set(id, resolution);
88
+ }
89
+ return resolution;
90
+ }
91
+ /**
92
+ * A canvas `font` string that draws the face the DOM uses for `css`.
93
+ *
94
+ * Canvas and DOM text resolve a family list through the same fonts, but
95
+ * not always to the same face: WebKit's DOM skips `ui-monospace` in a
96
+ * list that its canvas honours. When the two disagree on a sample's
97
+ * width, the shortest tail of the family list that canvas draws as
98
+ * wide as the DOM draws the whole list stands in for it.
99
+ *
100
+ * When no tail matches, the face is right but its size is not: Firefox's
101
+ * canvas snaps fractional font sizes to steps its DOM does not use. The
102
+ * font is then drawn as asked, and measured by the DOM.
103
+ */
104
+ #resolve(css) {
105
+ const match = /^(\S+ \S+ \S+px) (.+)$/.exec(css);
106
+ if (match === null) {
107
+ return { font: css, domWidths: false, ligatures: [] };
108
+ }
109
+ const face = this.#face(css, match);
110
+ return { ...face, ligatures: this.#ligatures(face.font, css) };
111
+ }
112
+ /** The canvas font that draws `css`'s face, and whether its widths come from the DOM. */
113
+ #face(css, match) {
114
+ const [, prefix = "", list = ""] = match;
115
+ const dom = this.#domWidth(css, RESOLUTION_SAMPLE);
116
+ const families = splitFamilies(list);
117
+ for (let start = 0; start < families.length; start += 1) {
118
+ const candidate = `${prefix} ${families.slice(start).join(", ")}`;
119
+ this.#context.font = candidate;
120
+ if (Math.abs(this.#context.measureText(RESOLUTION_SAMPLE).width - dom) <=
121
+ SAME_FACE_TOLERANCE) {
122
+ this.#font = "";
123
+ return { font: candidate, domWidths: false };
124
+ }
125
+ }
126
+ this.#font = "";
127
+ return { font: css, domWidths: true };
128
+ }
129
+ /** The width the DOM lays `text` out at in `font`. */
130
+ #domWidth(font, text) {
131
+ const span = this.#document.createElement("span");
132
+ span.setAttribute("aria-hidden", "true");
133
+ span.style.cssText =
134
+ `position:absolute;left:0;top:0;visibility:hidden;white-space:pre;font:${font};font-kerning:normal;font-variant:normal;letter-spacing:0;word-spacing:0`;
135
+ span.textContent = text;
136
+ this.#document.body.append(span);
137
+ const width = span.getBoundingClientRect().width;
138
+ span.remove();
139
+ return width;
140
+ }
141
+ addFont(id, css) {
142
+ this.#fonts[id] = css;
143
+ }
144
+ measure(metrics, lineFonts, lineHeights, fonts, text, lengths) {
145
+ const out = [];
146
+ const used = new Map();
147
+ const boxes = [
148
+ ...Array.from(metrics, (id) => ({ font: this.#css(id), lineHeight: null })),
149
+ ...Array.from(lineFonts, (id, index) => ({
150
+ font: this.#css(id),
151
+ lineHeight: lineHeights[index] ?? 0,
152
+ })),
153
+ ];
154
+ for (const box of this.#probe(boxes)) {
155
+ out.push(box[0], box[1]);
156
+ }
157
+ for (const { font } of boxes) {
158
+ used.set(font, used.get(font) ?? "");
159
+ }
160
+ let start = 0;
161
+ const fromDom = [];
162
+ for (let index = 0; index < lengths.length; index += 1) {
163
+ const length = lengths[index] ?? 0;
164
+ const segment = text.slice(start, start + length);
165
+ const id = fonts[index] ?? 0;
166
+ const font = this.#select(id);
167
+ const outIndex = out.length;
168
+ const joined = JOINING.test(segment);
169
+ const domWidths = this.#resolution(id).domWidths;
170
+ this.#pushAdvances(segment, joined, out, this.#resolution(id).ligatures);
171
+ if (joined || domWidths) {
172
+ const canvas = this.#context.measureText(segment).width;
173
+ fromDom.push({
174
+ font,
175
+ segment,
176
+ at: outIndex,
177
+ joined,
178
+ width: domWidths ? null : canvas,
179
+ });
180
+ }
181
+ used.set(font, (used.get(font) ?? "") + segment);
182
+ start += length;
183
+ }
184
+ this.#fromDom(fromDom, out);
185
+ this.#measured += lengths.length;
186
+ this.#calls += 1;
187
+ for (const listener of this.#listeners) {
188
+ listener(used);
189
+ }
190
+ return Float32Array.from(out);
191
+ }
192
+ /** Push `segment`'s advances, or zero placeholders for the DOM to replace when its letters join. */
193
+ #pushAdvances(segment, joined, out, ligatures) {
194
+ if (!joined) {
195
+ this.#advances(segment, out, ligatures);
196
+ return;
197
+ }
198
+ const start = out.length;
199
+ const scalars = countScalars(segment);
200
+ out.length += scalars;
201
+ out.fill(0, start, start + scalars);
202
+ }
203
+ /**
204
+ * Advances the DOM has to supply, in one layout pass.
205
+ *
206
+ * - In scripts whose letters join, a prefix of an Arabic word measured
207
+ * alone ends in a final form, not the joined form the letter has in the
208
+ * word, and canvas ignores a zero width joiner that would keep it
209
+ * joined. The DOM places each grapheme by its glyph in context, so its
210
+ * grapheme boxes give the advances.
211
+ * - Where canvas and DOM disagree on a font's size (see `#resolve`), the
212
+ * DOM's width of the whole segment is the one layout must wrap by; the
213
+ * canvas advances are scaled to it.
214
+ *
215
+ * `width` is the total the advances must sum to, or `null` to take the
216
+ * DOM's.
217
+ */
218
+ #fromDom(segments, out) {
219
+ if (segments.length === 0) {
220
+ return;
221
+ }
222
+ const document = this.#document;
223
+ const host = document.createElement("div");
224
+ host.setAttribute("aria-hidden", "true");
225
+ host.style.cssText =
226
+ "position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none";
227
+ const spans = segments.map(({ font, segment }) => {
228
+ const span = document.createElement("div");
229
+ span.style.cssText =
230
+ `font:${font};white-space:pre;width:max-content;font-kerning:normal;font-variant:normal;letter-spacing:0;word-spacing:0`;
231
+ span.textContent = segment;
232
+ host.append(span);
233
+ return span;
234
+ });
235
+ document.body.append(host);
236
+ const range = document.createRange();
237
+ segments.forEach((entry, index) => {
238
+ const span = spans[index];
239
+ const node = span?.firstChild;
240
+ if (span === undefined || node === null || node === undefined) {
241
+ return;
242
+ }
243
+ const total = entry.width ?? span.getBoundingClientRect().width;
244
+ if (entry.joined) {
245
+ this.#joinedAdvances(entry, range, node, total, out);
246
+ }
247
+ scale(out, entry.at, countScalars(entry.segment), total);
248
+ });
249
+ host.remove();
250
+ }
251
+ /**
252
+ * Write the advances of a segment whose letters join, read from `node`,
253
+ * its laid-out text. Each edge is the width of the prefix that ends
254
+ * there, read in the word's context: a browser that rounds rectangles to
255
+ * whole pixels (WebKit) then misplaces an edge by under a pixel, rather
256
+ * than accumulating each grapheme's rounding.
257
+ */
258
+ #joinedAdvances({ segment, at }, range, node, total, out) {
259
+ let scalar = 0;
260
+ let previous = 0;
261
+ for (const { index: offset, segment: cluster } of this.#graphemes.segment(segment)) {
262
+ const end = offset + cluster.length;
263
+ range.setStart(node, 0);
264
+ range.setEnd(node, end);
265
+ const edge = end === segment.length
266
+ ? total
267
+ : range.getBoundingClientRect().width;
268
+ out[at + scalar] = edge - previous;
269
+ previous = edge;
270
+ scalar += countScalars(cluster);
271
+ }
272
+ }
273
+ /** The CSS of font `id`. */
274
+ #css(id) {
275
+ const font = this.#fonts[id];
276
+ if (font === undefined) {
277
+ throw new Error(`canvist asked to measure unknown font ${id}`);
278
+ }
279
+ return font;
280
+ }
281
+ /**
282
+ * Read inline boxes from the DOM, in one layout pass: for a font alone,
283
+ * its content area's ascent and descent; with a unitless line height,
284
+ * the space its line box keeps above and below the baseline.
285
+ *
286
+ * Canvas reports font metrics rounded to whole CSS pixels, but browsers
287
+ * lay out lines with their own rounding: Blink rounds ascent and descent
288
+ * to device pixels and floors the half-leading, WebKit keeps 1/64 px,
289
+ * and Firefox keeps floats. Reading the boxes from real DOM text makes
290
+ * the canvas's line positions the browser's, whatever its rules.
291
+ */
292
+ #probe(boxes) {
293
+ if (boxes.length === 0) {
294
+ return [];
295
+ }
296
+ const document = this.#document;
297
+ const host = document.createElement("div");
298
+ host.setAttribute("aria-hidden", "true");
299
+ host.style.cssText =
300
+ "position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none";
301
+ const rows = boxes.map(({ font, lineHeight }) => {
302
+ const row = document.createElement("div");
303
+ // Unitless, as the server-rendered CSS writes it: browsers round a
304
+ // length and a multiple of the font size to layout units differently.
305
+ row.style.cssText = `font:${font};line-height:${lineHeight ?? "normal"};white-space:pre;width:max-content;font-kerning:normal;font-variant:normal;letter-spacing:0;word-spacing:0`;
306
+ const baseline = document.createElement("span");
307
+ baseline.style.cssText = "display:inline-block;width:0;height:0";
308
+ row.append(baseline, "x");
309
+ host.append(row);
310
+ return { row, baseline };
311
+ });
312
+ document.body.append(host);
313
+ const result = boxes.map(({ lineHeight }, index) => {
314
+ const { row, baseline } = rows[index] ?? rows[0];
315
+ const top = row.getBoundingClientRect().top;
316
+ const base = baseline.getBoundingClientRect().bottom - top;
317
+ if (lineHeight !== null) {
318
+ return [base, row.getBoundingClientRect().height - base];
319
+ }
320
+ const range = document.createRange();
321
+ range.selectNodeContents(row.lastChild ?? row);
322
+ const content = range.getBoundingClientRect();
323
+ return [base - (content.top - top), content.bottom - top - base];
324
+ });
325
+ host.remove();
326
+ return result;
327
+ }
328
+ /** Make font `id` current, as it is drawn, and return its CSS. */
329
+ #select(id) {
330
+ const font = this.paintFont(id);
331
+ if (font !== this.#font) {
332
+ this.#context.font = font;
333
+ this.#font = font;
334
+ }
335
+ return this.#css(id);
336
+ }
337
+ /**
338
+ * Push one advance per scalar value of `segment`.
339
+ *
340
+ * A shaper adds a kerning pair's adjustment to the first glyph's advance,
341
+ * and the browser places carets and selection edges by those advances.
342
+ * So the edge after grapheme `k` is the width of the first `k + 1`
343
+ * graphemes less the next grapheme's own width, and the last edge is the
344
+ * whole segment's width: the advances sum to what `fillText` paints, and
345
+ * each edge sits where the DOM puts it. Inside a ligature the browser
346
+ * splits the glyph's width instead, so edges there follow `ligatures`.
347
+ */
348
+ #advances(segment, out, ligatures) {
349
+ const clusters = this.#clusters(segment);
350
+ const edges = [];
351
+ let end = 0;
352
+ for (let index = 0; index < clusters.length; index += 1) {
353
+ end += (clusters[index] ?? "").length;
354
+ edges.push(this.#edge(segment, end, clusters[index + 1]));
355
+ }
356
+ splitLigatures(clusters, edges, ligatures);
357
+ let previous = 0;
358
+ clusters.forEach((cluster, index) => {
359
+ const edge = edges[index] ?? previous;
360
+ out.push(edge - previous);
361
+ previous = edge;
362
+ for (let scalars = countScalars(cluster); scalars > 1; scalars -= 1) {
363
+ out.push(0);
364
+ }
365
+ });
366
+ }
367
+ /**
368
+ * The sequences `canvasFont` draws as ligatures, found by comparing caret
369
+ * edges inside each candidate in the DOM (in `css`) with the canvas's, at
370
+ * a large size so whole-pixel rectangles barely round.
371
+ */
372
+ #ligatures(canvasFont, css) {
373
+ const document = this.#document;
374
+ const host = document.createElement("div");
375
+ host.setAttribute("aria-hidden", "true");
376
+ host.style.cssText =
377
+ "position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none";
378
+ const rows = LIGATURE_CANDIDATES.map((text) => {
379
+ const row = document.createElement("div");
380
+ row.style.cssText = `font:${atSize(css, LIGATURE_PROBE_PX)};white-space:pre;width:max-content;font-kerning:normal;font-variant:normal;letter-spacing:0;word-spacing:0`;
381
+ row.textContent = text;
382
+ host.append(row);
383
+ return row;
384
+ });
385
+ document.body.append(host);
386
+ this.#context.font = atSize(canvasFont, LIGATURE_PROBE_PX);
387
+ this.#font = "";
388
+ const range = document.createRange();
389
+ const found = [];
390
+ LIGATURE_CANDIDATES.forEach((text, index) => {
391
+ const node = rows[index]?.firstChild;
392
+ if (node === null || node === undefined) {
393
+ return;
394
+ }
395
+ const domWidth = rows[index]?.getBoundingClientRect().width ?? 0;
396
+ const canvasWidth = this.#context.measureText(text).width;
397
+ const edges = [];
398
+ let ligated = false;
399
+ for (let split = 1; split < text.length; split += 1) {
400
+ range.setStart(node, 0);
401
+ range.setEnd(node, split);
402
+ const dom = range.getBoundingClientRect().width / domWidth;
403
+ const canvas = (this.#context.measureText(text.slice(0, split + 1))
404
+ .width - this.#context.measureText(text.charAt(split)).width) /
405
+ canvasWidth;
406
+ ligated ||= Math.abs(dom - canvas) > LIGATURE_TOLERANCE;
407
+ edges.push(dom);
408
+ }
409
+ if (ligated && domWidth > 0 && canvasWidth > 0) {
410
+ found.push({ text, edges });
411
+ }
412
+ });
413
+ host.remove();
414
+ return found;
415
+ }
416
+ /**
417
+ * Where the caret edge after the cluster ending at `end` sits: the
418
+ * segment's whole width after the last cluster, otherwise the width up to
419
+ * and including `next` less `next` measured alone, so a kerning pair's
420
+ * adjustment stays with the first glyph.
421
+ */
422
+ #edge(segment, end, next) {
423
+ if (next === undefined) {
424
+ return this.#context.measureText(segment).width;
425
+ }
426
+ return this.#context.measureText(segment.slice(0, end + next.length))
427
+ .width - this.#alone(next);
428
+ }
429
+ /** The grapheme clusters of `segment`. */
430
+ #clusters(segment) {
431
+ if (isSimple(segment)) {
432
+ return [...segment];
433
+ }
434
+ return Array.from(this.#graphemes.segment(segment), (part) => part.segment);
435
+ }
436
+ /** The width of `cluster` on its own in the current font, cached per font. */
437
+ #alone(cluster) {
438
+ const key = `${this.#font}\u0000${cluster}`;
439
+ let width = this.#singles.get(key);
440
+ if (width === undefined) {
441
+ width = this.#context.measureText(cluster).width;
442
+ this.#singles.set(key, width);
443
+ }
444
+ return width;
445
+ }
446
+ }
447
+ /**
448
+ * Move the edges inside each ligature in `clusters` to where the DOM puts
449
+ * them: at the ligature's fractions of the width between its outer edges,
450
+ * which stay where the canvas measured them.
451
+ */
452
+ export function splitLigatures(clusters, edges, ligatures) {
453
+ for (let index = 0; index < clusters.length && ligatures.length > 0;) {
454
+ const ligature = ligatures.find(({ text }) => text.split("").every((letter, offset) => clusters[index + offset] === letter));
455
+ if (ligature === undefined) {
456
+ index += 1;
457
+ continue;
458
+ }
459
+ const start = index === 0 ? 0 : edges[index - 1] ?? 0;
460
+ const last = index + ligature.text.length - 1;
461
+ const width = (edges[last] ?? start) - start;
462
+ ligature.edges.forEach((share, offset) => {
463
+ edges[index + offset] = start + share * width;
464
+ });
465
+ index = last + 1;
466
+ }
467
+ }
468
+ /** `font` (a CSS `font` shorthand) at `size` CSS px. */
469
+ function atSize(font, size) {
470
+ return font.replace(/^(\S+ \S+ )\S+px/, `$1${size}px`);
471
+ }
472
+ /** Scale `count` advances from `at` so they sum to `total`. */
473
+ function scale(out, at, count, total) {
474
+ let sum = 0;
475
+ for (let index = at; index < at + count; index += 1) {
476
+ sum += out[index] ?? 0;
477
+ }
478
+ if (sum <= 0) {
479
+ return;
480
+ }
481
+ const factor = total / sum;
482
+ for (let index = at; index < at + count; index += 1) {
483
+ out[index] = (out[index] ?? 0) * factor;
484
+ }
485
+ }
486
+ /** The families of a CSS `font-family` list, splitting on commas outside quotes. */
487
+ export function splitFamilies(list) {
488
+ const families = [];
489
+ let current = "";
490
+ let quote = null;
491
+ for (const character of list) {
492
+ if (quote === null && character === ",") {
493
+ families.push(current.trim());
494
+ current = "";
495
+ continue;
496
+ }
497
+ current += character;
498
+ if (quote !== null) {
499
+ quote = character === quote ? null : quote;
500
+ }
501
+ else if (character === '"' || character === "'") {
502
+ quote = character;
503
+ }
504
+ }
505
+ families.push(current.trim());
506
+ return families.filter((family) => family !== "");
507
+ }
508
+ /** Whether every code unit of `text` is its own grapheme cluster. */
509
+ function isSimple(text) {
510
+ for (let index = 0; index < text.length; index += 1) {
511
+ if (text.charCodeAt(index) >= SIMPLE_TEXT_LIMIT) {
512
+ return false;
513
+ }
514
+ }
515
+ return true;
516
+ }
517
+ /** The number of Unicode scalar values in `text`. */
518
+ function countScalars(text) {
519
+ let count = 0;
520
+ for (const _ of text) {
521
+ count += 1;
522
+ }
523
+ return count;
524
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Menus that follow the selection: a bubble menu over selected text, and
3
+ * a floating menu beside an empty line, as in TipTap.
4
+ *
5
+ * Each takes an element you build (buttons, a link field, anything), keeps
6
+ * it next to the selection as it moves, scrolls, and resizes, and hides it
7
+ * when it doesn't apply (with `visibility: hidden`, so the page's own
8
+ * `display` stays). Pressing inside a menu keeps the editor's focus, so
9
+ * its buttons act on the selection.
10
+ *
11
+ * ```ts
12
+ * import { createEditor } from "@canvist/web";
13
+ * import { bubbleMenu } from "@canvist/web/menus";
14
+ *
15
+ * const editor = await createEditor(document.querySelector("#editor")!);
16
+ * const menu = bubbleMenu(editor, document.querySelector("#bubble")!);
17
+ * // menu.destroy() when you are done.
18
+ * ```
19
+ *
20
+ * @module
21
+ */
22
+ import type { Extensions } from "@canvist/headless";
23
+ import type { Editor } from "./editor.ts";
24
+ import type { Plugins } from "./plugin.ts";
25
+ /** The side of its anchor a menu prefers. It flips to the other side when it doesn't fit. */
26
+ export type Placement = "top" | "bottom" | "left" | "right";
27
+ /** Where to put a menu, from {@link placeMenu}. */
28
+ export interface PlacementOptions {
29
+ /** The side of the anchor to prefer. */
30
+ readonly placement: Placement;
31
+ /** The gap between the anchor and the menu, in CSS px. Default 8. */
32
+ readonly offset?: number;
33
+ /** The least distance between the menu and the viewport's edges, in CSS px. Default 8. */
34
+ readonly padding?: number;
35
+ }
36
+ /** Options for {@link bubbleMenu} and {@link floatingMenu}. */
37
+ export interface MenuOptions<E extends Extensions, P extends Plugins> extends Partial<PlacementOptions> {
38
+ /**
39
+ * Whether to show the menu now. It replaces the default rule, which
40
+ * the menu's own function documents.
41
+ */
42
+ readonly shouldShow?: (editor: Editor<E, P>) => boolean;
43
+ }
44
+ /** A menu attached to an editor. */
45
+ export interface Menu {
46
+ /** Whether the menu is showing. */
47
+ readonly visible: boolean;
48
+ /** Show, hide, and place the menu now, as after a change you made outside the editor. */
49
+ update(): void;
50
+ /** Stop following the editor and hide the menu. The element stays in the page. */
51
+ destroy(): void;
52
+ [Symbol.dispose](): void;
53
+ }
54
+ /**
55
+ * Keep `element` above selected text. By default it shows while the editor
56
+ * is editable and focused (or focus is in the menu) and the selection is a
57
+ * range or whole blocks, never a caret.
58
+ */
59
+ export declare function bubbleMenu<E extends Extensions, P extends Plugins>(editor: Editor<E, P>, element: HTMLElement, options?: MenuOptions<E, P>): Menu;
60
+ /**
61
+ * Keep `element` beside the caret on an empty line, for inserting blocks.
62
+ * By default it shows while the editor is editable and focused (or focus is
63
+ * in the menu) and the caret is in an empty text block.
64
+ */
65
+ export declare function floatingMenu<E extends Extensions, P extends Plugins>(editor: Editor<E, P>, element: HTMLElement, options?: MenuOptions<E, P>): Menu;
66
+ /**
67
+ * Position `element` (`position: fixed`) beside `anchor`, a rectangle in
68
+ * viewport coordinates, on the preferred side if it fits and the opposite
69
+ * side if not, then shifted along that side to stay inside the viewport.
70
+ */
71
+ export declare function placeMenu(element: HTMLElement, anchor: DOMRectReadOnly, options: PlacementOptions): void;