@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,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping measurements in step with the page's web fonts.
|
|
3
|
+
*
|
|
4
|
+
* The canvas resolves font families through `document.fonts`, so every face
|
|
5
|
+
* the page declares with `@font-face` or adds as a `FontFace` is usable with
|
|
6
|
+
* no configuration. But a face that is still loading measures and paints as
|
|
7
|
+
* its fallback. The watcher asks the browser to load the faces layout
|
|
8
|
+
* actually used, for the characters it used them for (so `unicode-range`
|
|
9
|
+
* subsets load only when needed), and reports each family as its faces
|
|
10
|
+
* finish loading, so layout forgets just those measurements.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/** The family name of a face, without the quotes `FontFace.family` may keep. */
|
|
15
|
+
export declare function familyName(face: FontFace): string;
|
|
16
|
+
/** Watches one `FontFaceSet` on behalf of one editor. */
|
|
17
|
+
export declare class FontWatcher {
|
|
18
|
+
#private;
|
|
19
|
+
constructor(fonts: FontFaceSet, loaded: (families: readonly string[]) => void);
|
|
20
|
+
/** Whether faces requested by the watcher are still loading. */
|
|
21
|
+
get loading(): boolean;
|
|
22
|
+
/** Resolve once every face the watcher asked for has loaded or failed. */
|
|
23
|
+
settled(): Promise<void>;
|
|
24
|
+
/** Load whatever faces the fonts in `used` need for their text and do not have yet. */
|
|
25
|
+
request(used: ReadonlyMap<string, string>): void;
|
|
26
|
+
/** Stop listening. */
|
|
27
|
+
dispose(): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping measurements in step with the page's web fonts.
|
|
3
|
+
*
|
|
4
|
+
* The canvas resolves font families through `document.fonts`, so every face
|
|
5
|
+
* the page declares with `@font-face` or adds as a `FontFace` is usable with
|
|
6
|
+
* no configuration. But a face that is still loading measures and paints as
|
|
7
|
+
* its fallback. The watcher asks the browser to load the faces layout
|
|
8
|
+
* actually used, for the characters it used them for (so `unicode-range`
|
|
9
|
+
* subsets load only when needed), and reports each family as its faces
|
|
10
|
+
* finish loading, so layout forgets just those measurements.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/** The family name of a face, without the quotes `FontFace.family` may keep. */
|
|
15
|
+
export function familyName(face) {
|
|
16
|
+
return face.family.replace(/^["']|["']$/g, "");
|
|
17
|
+
}
|
|
18
|
+
/** Watches one `FontFaceSet` on behalf of one editor. */
|
|
19
|
+
export class FontWatcher {
|
|
20
|
+
#fonts;
|
|
21
|
+
#loaded;
|
|
22
|
+
/** Characters already checked or loaded, per CSS font. */
|
|
23
|
+
#seen = new Map();
|
|
24
|
+
#pending = new Set();
|
|
25
|
+
#onLoadingDone;
|
|
26
|
+
constructor(fonts, loaded) {
|
|
27
|
+
this.#fonts = fonts;
|
|
28
|
+
this.#loaded = loaded;
|
|
29
|
+
this.#onLoadingDone = (event) => {
|
|
30
|
+
const families = event.fontfaces.map(familyName);
|
|
31
|
+
if (families.length > 0) {
|
|
32
|
+
loaded(families);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
fonts.addEventListener("loadingdone", this.#onLoadingDone);
|
|
36
|
+
}
|
|
37
|
+
/** Whether faces requested by the watcher are still loading. */
|
|
38
|
+
get loading() {
|
|
39
|
+
return this.#pending.size > 0;
|
|
40
|
+
}
|
|
41
|
+
/** Resolve once every face the watcher asked for has loaded or failed. */
|
|
42
|
+
async settled() {
|
|
43
|
+
while (this.#pending.size > 0) {
|
|
44
|
+
await Promise.allSettled([...this.#pending]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Load whatever faces the fonts in `used` need for their text and do not have yet. */
|
|
48
|
+
request(used) {
|
|
49
|
+
for (const [font, text] of used) {
|
|
50
|
+
const fresh = this.#unseen(font, text);
|
|
51
|
+
if (fresh.length === 0 || this.#fonts.check(font, fresh)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
this.#load(font, fresh);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The characters of `text` not yet checked for `font`, marking them seen.
|
|
59
|
+
* Empty text still checks one character, so the font's primary face loads.
|
|
60
|
+
*/
|
|
61
|
+
#unseen(font, text) {
|
|
62
|
+
const seen = this.#seen.get(font) ?? new Set();
|
|
63
|
+
this.#seen.set(font, seen);
|
|
64
|
+
let fresh = "";
|
|
65
|
+
for (const character of text.length === 0 ? "x" : text) {
|
|
66
|
+
if (!seen.has(character)) {
|
|
67
|
+
seen.add(character);
|
|
68
|
+
fresh += character;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return fresh;
|
|
72
|
+
}
|
|
73
|
+
/** Load the faces of `font` that cover `characters`, reporting their families once loaded. */
|
|
74
|
+
#load(font, characters) {
|
|
75
|
+
const pending = this.#fonts.load(font, characters).then((faces) => {
|
|
76
|
+
if (faces.length > 0) {
|
|
77
|
+
this.#loaded(faces.map(familyName));
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
// A face that fails to load keeps its fallback, as CSS does.
|
|
81
|
+
() => undefined).finally(() => this.#pending.delete(pending));
|
|
82
|
+
this.#pending.add(pending);
|
|
83
|
+
}
|
|
84
|
+
/** Stop listening. */
|
|
85
|
+
dispose() {
|
|
86
|
+
this.#fonts.removeEventListener("loadingdone", this.#onLoadingDone);
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/fonts.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fonts plugin: load fonts the way the web does, without jank.
|
|
3
|
+
*
|
|
4
|
+
* The editor already uses every font the page can: installed fonts by
|
|
5
|
+
* family name, and every face in `document.fonts`, whether the page
|
|
6
|
+
* declared it with `@font-face` or added a `FontFace`. This plugin adds:
|
|
7
|
+
*
|
|
8
|
+
* - **Loading** faces from URLs, bytes, or `local()` names with CSS
|
|
9
|
+
* descriptors (`weight`, `style`, `unicodeRange`, `display`, …). Faces
|
|
10
|
+
* with a `unicodeRange` load only once the document uses a character in
|
|
11
|
+
* it.
|
|
12
|
+
* - **`font-display`.** Text in a face that is still loading is laid out
|
|
13
|
+
* but not drawn for the block period, then drawn in the fallback until the
|
|
14
|
+
* face arrives. A face that arrives after its swap period (`fallback`,
|
|
15
|
+
* `optional`) is never swapped in, as in CSS.
|
|
16
|
+
* - **Metric-compatible fallbacks.** Each face gets a `"<family> Fallback"`
|
|
17
|
+
* face over a local font, scaled with `size-adjust` and
|
|
18
|
+
* `ascent-override`/`descent-override` to the real face's metrics, so
|
|
19
|
+
* text laid out in the fallback wraps almost where the real face will.
|
|
20
|
+
* Metrics come from `metrics`, or from measuring the face once it loads
|
|
21
|
+
* (remembered for the next visit). Use {@link stack} to put it in a
|
|
22
|
+
* family list.
|
|
23
|
+
* - **Discovery**: whether a family is available, and the installed fonts
|
|
24
|
+
* through the Local Font Access API where the browser has it.
|
|
25
|
+
* - **Server rendering**: {@link fontFaceCss} writes the same faces as
|
|
26
|
+
* `@font-face` rules, with no DOM.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { createEditor } from "@canvist/web";
|
|
30
|
+
* import { fonts, stack } from "@canvist/web/fonts";
|
|
31
|
+
*
|
|
32
|
+
* const editor = await createEditor(element, {
|
|
33
|
+
* theme: { bodyFamily: stack("Inter", "sans-serif") },
|
|
34
|
+
* plugins: [fonts({ faces: [{ family: "Inter", source: "/fonts/Inter.woff2", weight: "100 900" }] })],
|
|
35
|
+
* });
|
|
36
|
+
* const families = await editor.plugins.fonts.local();
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @module
|
|
40
|
+
*/
|
|
41
|
+
import type { Plugin } from "./plugin.ts";
|
|
42
|
+
declare global {
|
|
43
|
+
interface FontFaceDescriptors {
|
|
44
|
+
/** CSS Fonts 5 `size-adjust`, shipped in every engine but missing from TypeScript's DOM types. */
|
|
45
|
+
sizeAdjust?: string;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A face's vertical metrics and average width, as fractions of its em. */
|
|
49
|
+
export interface FaceMetrics {
|
|
50
|
+
readonly ascent: number;
|
|
51
|
+
readonly descent: number;
|
|
52
|
+
/** The average advance of {@link WIDTH_SAMPLE}'s characters. */
|
|
53
|
+
readonly averageWidth: number;
|
|
54
|
+
}
|
|
55
|
+
/** A font face to load, described as `@font-face` describes one. */
|
|
56
|
+
export interface FontSource {
|
|
57
|
+
/** The family name to register the face under. */
|
|
58
|
+
readonly family: string;
|
|
59
|
+
/** A URL, several URLs (tried in order), or the font file's bytes. */
|
|
60
|
+
readonly source: string | URL | readonly (string | URL)[] | ArrayBuffer | ArrayBufferView;
|
|
61
|
+
/** CSS `font-weight`, or a range such as `"100 900"` for variable fonts. */
|
|
62
|
+
readonly weight?: string;
|
|
63
|
+
/** CSS `font-style`: `"normal"`, `"italic"`, or `"oblique 0deg 10deg"`. */
|
|
64
|
+
readonly style?: string;
|
|
65
|
+
readonly stretch?: string;
|
|
66
|
+
/** Load the face only for text in these ranges, e.g. `"U+0000-00FF"`. */
|
|
67
|
+
readonly unicodeRange?: string;
|
|
68
|
+
/** What to show while the face loads. Default `"swap"`. */
|
|
69
|
+
readonly display?: FontDisplay;
|
|
70
|
+
readonly featureSettings?: string;
|
|
71
|
+
readonly variationSettings?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Local fonts to build the metric-compatible fallback over, best first,
|
|
74
|
+
* or `false` for none. Default Arial, Helvetica, Liberation Sans, Roboto.
|
|
75
|
+
*/
|
|
76
|
+
readonly fallback?: readonly string[] | false;
|
|
77
|
+
/** The face's metrics, so the fallback is right on the first visit too. */
|
|
78
|
+
readonly metrics?: FaceMetrics;
|
|
79
|
+
}
|
|
80
|
+
/** Options for {@link fonts}. */
|
|
81
|
+
export interface FontsOptions {
|
|
82
|
+
/** Faces to load as the editor starts. */
|
|
83
|
+
readonly faces?: readonly FontSource[];
|
|
84
|
+
}
|
|
85
|
+
/** One installed family, from {@link FontsApi.local}. */
|
|
86
|
+
export interface LocalFamily {
|
|
87
|
+
readonly family: string;
|
|
88
|
+
/** Its faces' style names, such as `"Regular"` and `"Bold Italic"`. */
|
|
89
|
+
readonly styles: readonly string[];
|
|
90
|
+
}
|
|
91
|
+
/** `editor.plugins.fonts`. */
|
|
92
|
+
export interface FontsApi {
|
|
93
|
+
/** Load a face, as `@font-face` would declare it. Resolves once it is usable or has failed over to its fallback. */
|
|
94
|
+
load(face: FontSource): Promise<FontFace>;
|
|
95
|
+
/** Whether text in `family` would render in that family rather than a fallback. */
|
|
96
|
+
available(family: string): boolean;
|
|
97
|
+
/**
|
|
98
|
+
* The installed font families, through the Local Font Access API. Ask in
|
|
99
|
+
* response to a click: browsers require a user gesture and permission.
|
|
100
|
+
* `null` where the API is missing (only Chromium has it) or permission is
|
|
101
|
+
* denied.
|
|
102
|
+
*/
|
|
103
|
+
local(): Promise<readonly LocalFamily[] | null>;
|
|
104
|
+
/** Every face this plugin added to the page. */
|
|
105
|
+
faces(): readonly FontFace[];
|
|
106
|
+
/** `@font-face` rules for every face loaded from a URL, for server rendering. */
|
|
107
|
+
css(): string;
|
|
108
|
+
}
|
|
109
|
+
/** The fonts plugin. */
|
|
110
|
+
export declare function fonts(options?: FontsOptions): Plugin<"fonts", FontsApi>;
|
|
111
|
+
/**
|
|
112
|
+
* A CSS `font-family` list that puts `family`'s metric-compatible fallback
|
|
113
|
+
* right after it: `stack("Inter", "sans-serif")` is
|
|
114
|
+
* `"Inter", "Inter Fallback", sans-serif`.
|
|
115
|
+
*/
|
|
116
|
+
export declare function stack(family: string, ...fallbacks: readonly string[]): string;
|
|
117
|
+
/**
|
|
118
|
+
* `@font-face` rules for `faces` loaded from URLs, so server-rendered HTML
|
|
119
|
+
* loads the same files the editor does. Runs anywhere, with no DOM. Faces
|
|
120
|
+
* given as bytes have no URL to point at and are skipped.
|
|
121
|
+
*/
|
|
122
|
+
export declare function fontFaceCss(faces: readonly FontSource[]): string;
|
|
123
|
+
/** The metrics of `family` as the canvas renders it now. */
|
|
124
|
+
export declare function measureFace(document: Document, family: string): FaceMetrics;
|
|
125
|
+
/**
|
|
126
|
+
* Whether `family` renders as itself: a probe string measures differently
|
|
127
|
+
* from both the serif and the monospace generic when the family falls
|
|
128
|
+
* back to neither.
|
|
129
|
+
*/
|
|
130
|
+
export declare function isAvailable(document: Document, family: string): boolean;
|
package/dist/fonts.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/** The suffix of a face's metric-compatible fallback family. */
|
|
2
|
+
const FALLBACK_SUFFIX = " Fallback";
|
|
3
|
+
/** Text whose average advance stands for a face's average character width. */
|
|
4
|
+
const WIDTH_SAMPLE = "The quick brown fox jumps over the lazy dog 0123456789";
|
|
5
|
+
/** The size faces are measured at, large enough that rounding doesn't matter. */
|
|
6
|
+
const PROBE_SIZE = 100;
|
|
7
|
+
/** Where measured metrics are remembered between visits. */
|
|
8
|
+
const STORAGE_PREFIX = "canvist:font-metrics:";
|
|
9
|
+
/** Local fonts to build fallbacks over when a face names none, tried in order. */
|
|
10
|
+
const DEFAULT_FALLBACKS = ["Arial", "Helvetica", "Liberation Sans", "Roboto"];
|
|
11
|
+
/** CSS Fonts 4's recommended short block and short swap periods, in ms. */
|
|
12
|
+
const SHORT_PERIOD_MS = 3000;
|
|
13
|
+
/** CSS Fonts 4's recommended extremely small block period, in ms. */
|
|
14
|
+
const EXTREMELY_SMALL_PERIOD_MS = 100;
|
|
15
|
+
/** How long text in a loading face stays invisible, and how long a late face may still swap in, per CSS Fonts 4. */
|
|
16
|
+
const DISPLAY_PERIODS = {
|
|
17
|
+
auto: { block: SHORT_PERIOD_MS, swap: Infinity },
|
|
18
|
+
block: { block: SHORT_PERIOD_MS, swap: Infinity },
|
|
19
|
+
swap: { block: 0, swap: Infinity },
|
|
20
|
+
fallback: { block: EXTREMELY_SMALL_PERIOD_MS, swap: SHORT_PERIOD_MS },
|
|
21
|
+
optional: {
|
|
22
|
+
block: EXTREMELY_SMALL_PERIOD_MS,
|
|
23
|
+
swap: EXTREMELY_SMALL_PERIOD_MS,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
/** The fonts plugin. */
|
|
27
|
+
export function fonts(options = {}) {
|
|
28
|
+
return {
|
|
29
|
+
name: "fonts",
|
|
30
|
+
setup(context) {
|
|
31
|
+
const loader = new FaceLoader(context);
|
|
32
|
+
for (const face of options.faces ?? []) {
|
|
33
|
+
context.waitFor(loader.load(face));
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
load: (face) => loader.load(face),
|
|
37
|
+
available: (family) => isAvailable(context.element.ownerDocument, family),
|
|
38
|
+
local: () => localFamilies(),
|
|
39
|
+
faces: () => loader.added(),
|
|
40
|
+
css: () => fontFaceCss(loader.sources()),
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A CSS `font-family` list that puts `family`'s metric-compatible fallback
|
|
47
|
+
* right after it: `stack("Inter", "sans-serif")` is
|
|
48
|
+
* `"Inter", "Inter Fallback", sans-serif`.
|
|
49
|
+
*/
|
|
50
|
+
export function stack(family, ...fallbacks) {
|
|
51
|
+
return [quote(family), quote(family + FALLBACK_SUFFIX), ...fallbacks].join(", ");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `@font-face` rules for `faces` loaded from URLs, so server-rendered HTML
|
|
55
|
+
* loads the same files the editor does. Runs anywhere, with no DOM. Faces
|
|
56
|
+
* given as bytes have no URL to point at and are skipped.
|
|
57
|
+
*/
|
|
58
|
+
export function fontFaceCss(faces) {
|
|
59
|
+
return faces.flatMap((face) => {
|
|
60
|
+
const urls = sourceUrls(face.source);
|
|
61
|
+
if (urls === null) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const declarations = [
|
|
65
|
+
`font-family:${quote(face.family)}`,
|
|
66
|
+
`src:${urls.map((url) => `url(${JSON.stringify(String(url))})`).join(",")}`,
|
|
67
|
+
face.weight === undefined ? "" : `font-weight:${face.weight}`,
|
|
68
|
+
face.style === undefined ? "" : `font-style:${face.style}`,
|
|
69
|
+
face.stretch === undefined ? "" : `font-stretch:${face.stretch}`,
|
|
70
|
+
face.unicodeRange === undefined
|
|
71
|
+
? ""
|
|
72
|
+
: `unicode-range:${face.unicodeRange}`,
|
|
73
|
+
`font-display:${face.display ?? "swap"}`,
|
|
74
|
+
].filter((declaration) => declaration !== "");
|
|
75
|
+
return [`@font-face{${declarations.join(";")}}`];
|
|
76
|
+
}).join("\n");
|
|
77
|
+
}
|
|
78
|
+
/** Loads faces into one document for one editor. */
|
|
79
|
+
class FaceLoader {
|
|
80
|
+
#context;
|
|
81
|
+
#document;
|
|
82
|
+
#added = [];
|
|
83
|
+
#sources = [];
|
|
84
|
+
#blocked = new Map();
|
|
85
|
+
constructor(context) {
|
|
86
|
+
this.#context = context;
|
|
87
|
+
this.#document = context.element.ownerDocument;
|
|
88
|
+
}
|
|
89
|
+
added() {
|
|
90
|
+
return this.#added;
|
|
91
|
+
}
|
|
92
|
+
sources() {
|
|
93
|
+
return this.#sources;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Add `source` the way CSS `font-display` would: subsets are added
|
|
97
|
+
* unloaded for the font watcher to load on demand; whole faces hide their
|
|
98
|
+
* text for the block period, and are only swapped in if they arrive within
|
|
99
|
+
* the swap period. A network failure keeps the fallback, like CSS.
|
|
100
|
+
*/
|
|
101
|
+
async load(source) {
|
|
102
|
+
this.#sources.push(source);
|
|
103
|
+
const display = source.display ?? "swap";
|
|
104
|
+
const periods = DISPLAY_PERIODS[display];
|
|
105
|
+
const face = new FontFace(source.family, faceSource(source.source), {
|
|
106
|
+
display,
|
|
107
|
+
...descriptors(source),
|
|
108
|
+
});
|
|
109
|
+
const fallback = source.fallback === false
|
|
110
|
+
? undefined
|
|
111
|
+
: this.#fallback(source, source.fallback ?? DEFAULT_FALLBACKS);
|
|
112
|
+
if (source.unicodeRange !== undefined) {
|
|
113
|
+
// Subsets load when text needs them: the editor's font watcher
|
|
114
|
+
// asks for exactly the characters the document uses.
|
|
115
|
+
this.#add(face);
|
|
116
|
+
await fallback;
|
|
117
|
+
return face;
|
|
118
|
+
}
|
|
119
|
+
const started = performance.now();
|
|
120
|
+
this.#block(source.family, periods.block);
|
|
121
|
+
try {
|
|
122
|
+
await face.load();
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (!(error instanceof DOMException && error.name === "NetworkError")) {
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
// A face that fails to load leaves its fallback in place, as CSS does.
|
|
129
|
+
this.#unblock(source.family);
|
|
130
|
+
await fallback;
|
|
131
|
+
return face;
|
|
132
|
+
}
|
|
133
|
+
this.#unblock(source.family);
|
|
134
|
+
if (performance.now() - started <= periods.swap) {
|
|
135
|
+
this.#add(face);
|
|
136
|
+
this.#remember(source);
|
|
137
|
+
this.#context.fontsLoaded([source.family]);
|
|
138
|
+
}
|
|
139
|
+
await fallback;
|
|
140
|
+
return face;
|
|
141
|
+
}
|
|
142
|
+
#add(face) {
|
|
143
|
+
this.#document.fonts.add(face);
|
|
144
|
+
this.#added.push(face);
|
|
145
|
+
this.#context.signal.addEventListener("abort", () => this.#document.fonts.delete(face), { once: true });
|
|
146
|
+
}
|
|
147
|
+
/** Hide `family`'s text for `period` milliseconds, or until it loads. */
|
|
148
|
+
#block(family, period) {
|
|
149
|
+
if (period <= 0) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const timer = window.setTimeout(() => this.#unblock(family), period);
|
|
153
|
+
this.#blocked.set(family, timer);
|
|
154
|
+
this.#context.hideFamilies([...this.#blocked.keys()]);
|
|
155
|
+
}
|
|
156
|
+
#unblock(family) {
|
|
157
|
+
const timer = this.#blocked.get(family);
|
|
158
|
+
if (timer === undefined) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
this.#blocked.delete(family);
|
|
163
|
+
this.#context.hideFamilies([...this.#blocked.keys()]);
|
|
164
|
+
}
|
|
165
|
+
/** Add a metric-compatible fallback for `source` over the first of `locals` installed. */
|
|
166
|
+
async #fallback(source, locals) {
|
|
167
|
+
const target = source.metrics ?? recall(source.family);
|
|
168
|
+
if (target === undefined) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const local of locals) {
|
|
172
|
+
const probe = new FontFace(`${source.family}${FALLBACK_SUFFIX}`, `local(${quote(local)})`);
|
|
173
|
+
if (!(await installed(probe))) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const base = measureFace(this.#document, quote(local));
|
|
177
|
+
const sizeAdjust = target.averageWidth / base.averageWidth;
|
|
178
|
+
const face = new FontFace(`${source.family}${FALLBACK_SUFFIX}`, `local(${quote(local)})`, {
|
|
179
|
+
...descriptors(source),
|
|
180
|
+
sizeAdjust: percent(sizeAdjust),
|
|
181
|
+
ascentOverride: percent(target.ascent / sizeAdjust),
|
|
182
|
+
descentOverride: percent(target.descent / sizeAdjust),
|
|
183
|
+
lineGapOverride: "0%",
|
|
184
|
+
});
|
|
185
|
+
await face.load();
|
|
186
|
+
this.#add(face);
|
|
187
|
+
this.#context.fontsLoaded([face.family]);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Measure a loaded face and keep its metrics for the next visit's fallback. */
|
|
192
|
+
#remember(source) {
|
|
193
|
+
if (source.metrics !== undefined) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const metrics = measureFace(this.#document, quote(source.family));
|
|
197
|
+
try {
|
|
198
|
+
localStorage.setItem(STORAGE_PREFIX + source.family, JSON.stringify(metrics));
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
// Storage can be full or disabled (private browsing); the next
|
|
202
|
+
// visit then measures again, and nothing else depends on it.
|
|
203
|
+
if (!(error instanceof DOMException)) {
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Whether the `local()` face `probe` loads: it fails with a NetworkError when the font is not installed. */
|
|
210
|
+
async function installed(probe) {
|
|
211
|
+
try {
|
|
212
|
+
await probe.load();
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (error instanceof DOMException && error.name === "NetworkError") {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Metrics measured on a previous visit, if any were stored. */
|
|
223
|
+
function recall(family) {
|
|
224
|
+
let stored;
|
|
225
|
+
try {
|
|
226
|
+
stored = localStorage.getItem(STORAGE_PREFIX + family);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (error instanceof DOMException) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
if (stored === null) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
const parsed = JSON.parse(stored);
|
|
238
|
+
return isFaceMetrics(parsed) ? parsed : undefined;
|
|
239
|
+
}
|
|
240
|
+
function isFaceMetrics(value) {
|
|
241
|
+
return typeof value === "object" && value !== null &&
|
|
242
|
+
["ascent", "descent", "averageWidth"].every((key) => typeof value[key] === "number");
|
|
243
|
+
}
|
|
244
|
+
/** The metrics of `family` as the canvas renders it now. */
|
|
245
|
+
export function measureFace(document, family) {
|
|
246
|
+
const context = document.createElement("canvas").getContext("2d");
|
|
247
|
+
if (context === null) {
|
|
248
|
+
throw new Error("canvist needs a 2D canvas context to measure fonts");
|
|
249
|
+
}
|
|
250
|
+
context.font = `${PROBE_SIZE}px ${family}`;
|
|
251
|
+
const box = context.measureText(WIDTH_SAMPLE);
|
|
252
|
+
return {
|
|
253
|
+
ascent: box.fontBoundingBoxAscent / PROBE_SIZE,
|
|
254
|
+
descent: box.fontBoundingBoxDescent / PROBE_SIZE,
|
|
255
|
+
averageWidth: box.width / PROBE_SIZE / WIDTH_SAMPLE.length,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether `family` renders as itself: a probe string measures differently
|
|
260
|
+
* from both the serif and the monospace generic when the family falls
|
|
261
|
+
* back to neither.
|
|
262
|
+
*/
|
|
263
|
+
export function isAvailable(document, family) {
|
|
264
|
+
const context = document.createElement("canvas").getContext("2d");
|
|
265
|
+
if (context === null) {
|
|
266
|
+
throw new Error("canvist needs a 2D canvas context to measure fonts");
|
|
267
|
+
}
|
|
268
|
+
const probe = "mmmmmmmmmmlli10OoWw@#";
|
|
269
|
+
return ["monospace", "serif"].some((generic) => {
|
|
270
|
+
context.font = `${PROBE_SIZE}px ${generic}`;
|
|
271
|
+
const base = context.measureText(probe).width;
|
|
272
|
+
context.font = `${PROBE_SIZE}px ${quote(family)}, ${generic}`;
|
|
273
|
+
return context.measureText(probe).width !== base;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The installed families and their styles, from the Local Font Access API.
|
|
278
|
+
* `null` when the browser has no such API or the user denies permission,
|
|
279
|
+
* so callers can tell "unknown" apart from "no fonts".
|
|
280
|
+
*/
|
|
281
|
+
async function localFamilies() {
|
|
282
|
+
const query = globalThis.queryLocalFonts;
|
|
283
|
+
if (query === undefined) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
let fonts;
|
|
287
|
+
try {
|
|
288
|
+
fonts = await query();
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (error instanceof DOMException &&
|
|
292
|
+
(error.name === "NotAllowedError" || error.name === "SecurityError")) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
const families = new Map();
|
|
298
|
+
for (const font of fonts) {
|
|
299
|
+
const styles = families.get(font.family) ?? [];
|
|
300
|
+
styles.push(font.style);
|
|
301
|
+
families.set(font.family, styles);
|
|
302
|
+
}
|
|
303
|
+
return [...families].map(([family, styles]) => ({ family, styles }));
|
|
304
|
+
}
|
|
305
|
+
function descriptors(source) {
|
|
306
|
+
const result = {};
|
|
307
|
+
const entries = {
|
|
308
|
+
weight: source.weight,
|
|
309
|
+
style: source.style,
|
|
310
|
+
stretch: source.stretch,
|
|
311
|
+
unicodeRange: source.unicodeRange,
|
|
312
|
+
featureSettings: source.featureSettings,
|
|
313
|
+
variationSettings: source.variationSettings,
|
|
314
|
+
};
|
|
315
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
316
|
+
if (value !== undefined) {
|
|
317
|
+
result[key] = value;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
function faceSource(source) {
|
|
323
|
+
const urls = sourceUrls(source);
|
|
324
|
+
if (urls === null) {
|
|
325
|
+
return source;
|
|
326
|
+
}
|
|
327
|
+
return urls.map((url) => `url(${JSON.stringify(String(url))})`).join(", ");
|
|
328
|
+
}
|
|
329
|
+
function sourceUrls(source) {
|
|
330
|
+
if (typeof source === "string" || source instanceof URL) {
|
|
331
|
+
return [source];
|
|
332
|
+
}
|
|
333
|
+
if (Array.isArray(source)) {
|
|
334
|
+
return source;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
function quote(family) {
|
|
339
|
+
return JSON.stringify(family);
|
|
340
|
+
}
|
|
341
|
+
function percent(ratio) {
|
|
342
|
+
return `${(ratio * 100).toFixed(4)}%`;
|
|
343
|
+
}
|
package/dist/full.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor with every extension canvist ships.
|
|
3
|
+
*
|
|
4
|
+
* `createEditor` here makes an editor from a build of canvist with every
|
|
5
|
+
* extension inside, and the layout and theme for their blocks and marks:
|
|
6
|
+
* tables, callouts, math, media, ink, and the rest. It is a third
|
|
7
|
+
* WebAssembly module, so editors with only the StarterKit never download
|
|
8
|
+
* it. Everything `@canvist/web` exports is here too, and every extension
|
|
9
|
+
* beyond the StarterKit; `createEditor` here takes them all, and `load`
|
|
10
|
+
* loads this module.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { allExtensions, createEditor } from "@canvist/web/full";
|
|
14
|
+
*
|
|
15
|
+
* const editor = await createEditor(document.querySelector("#editor")!, {
|
|
16
|
+
* extensions: allExtensions,
|
|
17
|
+
* content: { format: "markdown", input: "| a | b |\n| - | - |\n| 1 | 2 |" },
|
|
18
|
+
* });
|
|
19
|
+
* editor.commands.addRowAfter();
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import type { Extensions, StarterKit } from "@canvist/headless";
|
|
25
|
+
import { Editor, type EditorOptions } from "./editor.ts";
|
|
26
|
+
import type { WasmSource } from "./load.ts";
|
|
27
|
+
import type { Plugins } from "./plugin.ts";
|
|
28
|
+
export * from "@canvist/headless/extras";
|
|
29
|
+
export * from "./mod.ts";
|
|
30
|
+
/**
|
|
31
|
+
* Load the full WebAssembly module once. Without a source it is fetched
|
|
32
|
+
* from next to this file, which bundlers such as Vite follow.
|
|
33
|
+
*/
|
|
34
|
+
export declare function load(source?: WasmSource): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Create an editor that can use any extension: `@canvist/web`'s
|
|
37
|
+
* `createEditor`, with every extension inside. `options.wasm` is where the
|
|
38
|
+
* full module comes from.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createEditor<const E extends Extensions = StarterKit, const P extends Plugins = []>(element: HTMLElement, options?: EditorOptions<E, P>): Promise<Editor<E, P>>;
|
package/dist/full.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import init, { WebEditor } from "../wasm-full/canvist_web.js";
|
|
2
|
+
import { Editor } from "./editor.js";
|
|
3
|
+
export * from "@canvist/headless/extras";
|
|
4
|
+
export * from "./mod.js";
|
|
5
|
+
let loading;
|
|
6
|
+
/**
|
|
7
|
+
* Load the full WebAssembly module once. Without a source it is fetched
|
|
8
|
+
* from next to this file, which bundlers such as Vite follow.
|
|
9
|
+
*/
|
|
10
|
+
export function load(source) {
|
|
11
|
+
loading ??= init({
|
|
12
|
+
module_or_path: source ??
|
|
13
|
+
new URL("../wasm-full/canvist_web_bg.wasm", import.meta.url),
|
|
14
|
+
}).then(() => undefined);
|
|
15
|
+
return loading;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create an editor that can use any extension: `@canvist/web`'s
|
|
19
|
+
* `createEditor`, with every extension inside. `options.wasm` is where the
|
|
20
|
+
* full module comes from.
|
|
21
|
+
*/
|
|
22
|
+
export async function createEditor(element, options = {}) {
|
|
23
|
+
await load(options.wasm);
|
|
24
|
+
return new Editor(element, options, (...args) => new WebEditor(...args));
|
|
25
|
+
}
|