@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
|
@@ -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[];
|
package/dist/measure.js
ADDED
|
@@ -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
|
+
}
|
package/dist/menus.d.ts
ADDED
|
@@ -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;
|