@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.
Files changed (45) 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 +86 -5
  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/README.md +0 -3
package/dist/menus.js ADDED
@@ -0,0 +1,173 @@
1
+ /** The gap between a menu and its anchor, in CSS px. */
2
+ const DEFAULT_OFFSET = 8;
3
+ /** The least distance between a menu and the viewport's edges, in CSS px. */
4
+ const DEFAULT_PADDING = 8;
5
+ /**
6
+ * Keep `element` above selected text. By default it shows while the editor
7
+ * is editable and focused (or focus is in the menu) and the selection is a
8
+ * range or whole blocks, never a caret.
9
+ */
10
+ export function bubbleMenu(editor, element, options = {}) {
11
+ return attach(editor, element, {
12
+ placement: options.placement ?? "top",
13
+ offset: options.offset ?? DEFAULT_OFFSET,
14
+ padding: options.padding ?? DEFAULT_PADDING,
15
+ shouldShow: options.shouldShow ?? showsBubble,
16
+ });
17
+ }
18
+ /**
19
+ * Keep `element` beside the caret on an empty line, for inserting blocks.
20
+ * By default it shows while the editor is editable and focused (or focus is
21
+ * in the menu) and the caret is in an empty text block.
22
+ */
23
+ export function floatingMenu(editor, element, options = {}) {
24
+ return attach(editor, element, {
25
+ placement: options.placement ?? "left",
26
+ offset: options.offset ?? DEFAULT_OFFSET,
27
+ padding: options.padding ?? DEFAULT_PADDING,
28
+ shouldShow: options.shouldShow ?? showsFloating,
29
+ });
30
+ }
31
+ /**
32
+ * Position `element` (`position: fixed`) beside `anchor`, a rectangle in
33
+ * viewport coordinates, on the preferred side if it fits and the opposite
34
+ * side if not, then shifted along that side to stay inside the viewport.
35
+ */
36
+ export function placeMenu(element, anchor, options) {
37
+ const offset = options.offset ?? DEFAULT_OFFSET;
38
+ const padding = options.padding ?? DEFAULT_PADDING;
39
+ const view = element.ownerDocument.defaultView;
40
+ const viewport = {
41
+ width: view?.innerWidth ?? 0,
42
+ height: view?.innerHeight ?? 0,
43
+ };
44
+ // Out of the flow first, so it measures at the size it shows at.
45
+ element.style.position = "fixed";
46
+ const size = element.getBoundingClientRect();
47
+ const placement = fits(options.placement, anchor, size, viewport, offset)
48
+ ? options.placement
49
+ : OPPOSITE[options.placement];
50
+ const vertical = placement === "top" || placement === "bottom";
51
+ const [x, y] = vertical
52
+ ? [
53
+ anchor.x + anchor.width / 2 - size.width / 2,
54
+ placement === "top"
55
+ ? anchor.top - offset - size.height
56
+ : anchor.bottom + offset,
57
+ ]
58
+ : [
59
+ placement === "left"
60
+ ? anchor.left - offset - size.width
61
+ : anchor.right + offset,
62
+ anchor.y + anchor.height / 2 - size.height / 2,
63
+ ];
64
+ Object.assign(element.style, {
65
+ left: `${clamp(x, padding, viewport.width - size.width - padding)}px`,
66
+ top: `${clamp(y, padding, viewport.height - size.height - padding)}px`,
67
+ });
68
+ element.dataset["placement"] = placement;
69
+ }
70
+ const OPPOSITE = {
71
+ top: "bottom",
72
+ bottom: "top",
73
+ left: "right",
74
+ right: "left",
75
+ };
76
+ /** Follow `editor` with `element` until the returned menu is destroyed. */
77
+ function attach(editor, element, attachment) {
78
+ const abort = new AbortController();
79
+ const signal = abort.signal;
80
+ const view = element.ownerDocument.defaultView;
81
+ let visible = false;
82
+ let frame = 0;
83
+ const update = () => {
84
+ cancelAnimationFrame(frame);
85
+ frame = 0;
86
+ const anchor = attachment.shouldShow(editor)
87
+ ? editor.selectionRect()
88
+ : null;
89
+ visible = anchor !== null;
90
+ element.style.visibility = visible ? "visible" : "hidden";
91
+ if (anchor !== null) {
92
+ placeMenu(element, editor.toClientRect(anchor), attachment);
93
+ }
94
+ };
95
+ // Edits and scrolling come in bursts: place once per frame.
96
+ const schedule = () => {
97
+ frame ||= requestAnimationFrame(update);
98
+ };
99
+ const unsubscribe = editor.subscribe(schedule);
100
+ // Focus inside the menu counts as focus in the editor.
101
+ element.dataset["canvistMenu"] = "";
102
+ // Hidden by visibility, so it keeps its size for placing, whatever
103
+ // `display` the page gives it.
104
+ element.hidden = false;
105
+ element.style.position = "fixed";
106
+ // Keep the editor's focus, and its selection, when the menu is pressed.
107
+ element.addEventListener("pointerdown", (event) => event.preventDefault(), {
108
+ signal,
109
+ });
110
+ element.addEventListener("focusout", schedule, { signal });
111
+ view?.addEventListener("scroll", schedule, {
112
+ capture: true,
113
+ passive: true,
114
+ signal,
115
+ });
116
+ view?.addEventListener("resize", schedule, { signal });
117
+ update();
118
+ const destroy = () => {
119
+ abort.abort();
120
+ unsubscribe();
121
+ cancelAnimationFrame(frame);
122
+ element.style.visibility = "hidden";
123
+ visible = false;
124
+ };
125
+ return {
126
+ get visible() {
127
+ return visible;
128
+ },
129
+ update,
130
+ destroy,
131
+ [Symbol.dispose]: destroy,
132
+ };
133
+ }
134
+ /** Whether `editor`, or a menu inside its document, holds focus while it is editable. */
135
+ function active(editor) {
136
+ if (!editor.editable) {
137
+ return false;
138
+ }
139
+ const focused = editor.canvas.ownerDocument.activeElement;
140
+ return editor.focused ||
141
+ (focused !== null && focused.closest("[data-canvist-menu]") !== null);
142
+ }
143
+ function showsBubble(editor) {
144
+ const selection = editor.selection;
145
+ return active(editor) &&
146
+ (selection === null || !samePosition(selection.anchor, selection.head));
147
+ }
148
+ function showsFloating(editor) {
149
+ const selection = editor.selection;
150
+ return active(editor) && selection !== null &&
151
+ samePosition(selection.anchor, selection.head) &&
152
+ editor.blockText() === "";
153
+ }
154
+ function samePosition(a, b) {
155
+ return a.offset === b.offset && a.path.length === b.path.length &&
156
+ a.path.every((step, index) => step === b.path[index]);
157
+ }
158
+ /** Whether a menu of `size` fits on `placement`'s side of `anchor`. */
159
+ function fits(placement, anchor, size, viewport, offset) {
160
+ switch (placement) {
161
+ case "top":
162
+ return anchor.top - offset - size.height >= 0;
163
+ case "bottom":
164
+ return anchor.bottom + offset + size.height <= viewport.height;
165
+ case "left":
166
+ return anchor.left - offset - size.width >= 0;
167
+ case "right":
168
+ return anchor.right + offset + size.width <= viewport.width;
169
+ }
170
+ }
171
+ function clamp(value, min, max) {
172
+ return Math.max(min, Math.min(value, Math.max(min, max)));
173
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The accessibility mirror: the document as semantic HTML, visually hidden
3
+ * beside the canvas, so screen readers and find-in-page tools can read
4
+ * what the canvas paints.
5
+ *
6
+ * It is the same HTML the server renders, refreshed when the browser is
7
+ * idle after a change rather than on every keystroke.
8
+ *
9
+ * @module
10
+ */
11
+ /** A visually hidden copy of the document for assistive technology. */
12
+ export declare class AccessibilityMirror {
13
+ #private;
14
+ /**
15
+ * A mirror inside `parent`, labelled `label`, whose content `render`
16
+ * returns as HTML.
17
+ */
18
+ constructor(parent: HTMLElement, label: string, render: () => string);
19
+ /** The document changed: refresh when the browser is next idle. */
20
+ schedule(): void;
21
+ /** Refresh now. */
22
+ refresh(): void;
23
+ dispose(): void;
24
+ }
package/dist/mirror.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The accessibility mirror: the document as semantic HTML, visually hidden
3
+ * beside the canvas, so screen readers and find-in-page tools can read
4
+ * what the canvas paints.
5
+ *
6
+ * It is the same HTML the server renders, refreshed when the browser is
7
+ * idle after a change rather than on every keystroke.
8
+ *
9
+ * @module
10
+ */
11
+ /** How long to wait for an idle moment before refreshing anyway. */
12
+ const IDLE_TIMEOUT_MS = 500;
13
+ /** A visually hidden copy of the document for assistive technology. */
14
+ export class AccessibilityMirror {
15
+ #element;
16
+ #render;
17
+ #pending = 0;
18
+ /**
19
+ * A mirror inside `parent`, labelled `label`, whose content `render`
20
+ * returns as HTML.
21
+ */
22
+ constructor(parent, label, render) {
23
+ this.#render = render;
24
+ this.#element = parent.ownerDocument.createElement("div");
25
+ this.#element.className = "cv-mirror";
26
+ this.#element.setAttribute("role", "document");
27
+ this.#element.setAttribute("aria-label", label);
28
+ Object.assign(this.#element.style, {
29
+ position: "absolute",
30
+ width: "1px",
31
+ height: "1px",
32
+ margin: "-1px",
33
+ padding: "0",
34
+ border: "0",
35
+ overflow: "hidden",
36
+ clipPath: "inset(50%)",
37
+ whiteSpace: "nowrap",
38
+ });
39
+ parent.append(this.#element);
40
+ // Serializing a long document costs milliseconds: not before first paint.
41
+ this.schedule();
42
+ }
43
+ /** The document changed: refresh when the browser is next idle. */
44
+ schedule() {
45
+ if (this.#pending !== 0) {
46
+ return;
47
+ }
48
+ this.#pending = typeof requestIdleCallback === "function"
49
+ ? requestIdleCallback(() => this.refresh(), { timeout: IDLE_TIMEOUT_MS })
50
+ : window.setTimeout(() => this.refresh(), IDLE_TIMEOUT_MS);
51
+ }
52
+ /** Refresh now. */
53
+ refresh() {
54
+ this.#cancel();
55
+ this.#element.innerHTML = this.#render();
56
+ }
57
+ dispose() {
58
+ this.#cancel();
59
+ this.#element.remove();
60
+ }
61
+ #cancel() {
62
+ if (this.#pending === 0) {
63
+ return;
64
+ }
65
+ if (typeof cancelIdleCallback === "function") {
66
+ cancelIdleCallback(this.#pending);
67
+ }
68
+ else {
69
+ clearTimeout(this.#pending);
70
+ }
71
+ this.#pending = 0;
72
+ }
73
+ }
package/dist/mod.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The canvist editor for the browser.
3
+ *
4
+ * canvist paints every pixel of the document on a canvas, measured by the
5
+ * browser itself, so the canvas wraps each line exactly where canvist's
6
+ * server-rendered HTML does and hydrating from that HTML moves nothing.
7
+ * Commands, queries, and plugins are typed from what you register: calling
8
+ * one you did not register is a type error.
9
+ *
10
+ * ```ts
11
+ * import { createEditor } from "@canvist/web";
12
+ *
13
+ * const editor = await createEditor(document.querySelector("#editor")!, {
14
+ * content: { format: "markdown", input: "Hello **world**" },
15
+ * });
16
+ * editor.select({ path: [0], offset: 0 }, { path: [0], offset: 5 });
17
+ * editor.commands.toggleItalic();
18
+ * ```
19
+ *
20
+ * @module
21
+ */
22
+ export type { CanvistError, CommandName, CommandParams, Commands, Content, DocJson, ErrorCode, ExtensionDescriptor, Extensions, Format, Position, Queries, QueryName, QueryParams, QueryResult, Range, Registered, StarterKit, ThemeOptions, } from "@canvist/headless";
23
+ export * from "@canvist/headless/extensions";
24
+ export { borderScale, createEditor, detectBreaks, Editor, type EditorOptions, type EditorStats, type FrameReport, type Geometry, type InputLatency, type LineBreaks, pixelGrid, } from "./editor.ts";
25
+ export { familyName, FontWatcher } from "./font-watcher.ts";
26
+ export { type InputMode, usesEditContext } from "./input.ts";
27
+ export { load, type WasmSource } from "./load.ts";
28
+ export { CanvasMeasurer, type MeasureListener } from "./measure.ts";
29
+ export { detectPlatform, type KeyPlatform } from "./platform.ts";
30
+ export type { Plugin, PluginApis, PluginContext, Plugins } from "./plugin.ts";
package/dist/mod.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from "@canvist/headless/extensions";
2
+ export { borderScale, createEditor, detectBreaks, Editor, pixelGrid, } from "./editor.js";
3
+ export { familyName, FontWatcher } from "./font-watcher.js";
4
+ export { usesEditContext } from "./input.js";
5
+ export { load } from "./load.js";
6
+ export { CanvasMeasurer } from "./measure.js";
7
+ export { detectPlatform } from "./platform.js";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The placeholder: dimmed text shown where the first line starts while the
3
+ * document is empty.
4
+ *
5
+ * It is a DOM element over the canvas, set in the body text's font and
6
+ * line height, so it sits exactly where typed text will appear. Assistive
7
+ * technology reads it from the text field's `aria-placeholder` instead.
8
+ *
9
+ * @module
10
+ */
11
+ /** Placeholder text over an empty document. */
12
+ export declare class Placeholder {
13
+ #private;
14
+ /**
15
+ * A placeholder inside `parent` in the body text's CSS `font` and
16
+ * `color`.
17
+ */
18
+ constructor(parent: HTMLElement, font: string, color: string);
19
+ /** The text to show. An empty string shows nothing. */
20
+ set text(text: string);
21
+ /**
22
+ * Show the placeholder on the line `line` covers, in document
23
+ * coordinates, or hide it for `null`.
24
+ */
25
+ show(line: DOMRectReadOnly | null): void;
26
+ dispose(): void;
27
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The placeholder: dimmed text shown where the first line starts while the
3
+ * document is empty.
4
+ *
5
+ * It is a DOM element over the canvas, set in the body text's font and
6
+ * line height, so it sits exactly where typed text will appear. Assistive
7
+ * technology reads it from the text field's `aria-placeholder` instead.
8
+ *
9
+ * @module
10
+ */
11
+ /**
12
+ * How strongly the placeholder shows the body text's colour: enough for
13
+ * 4.5:1 contrast on the StarterKit's light and dark pages, as WCAG asks of
14
+ * placeholder text.
15
+ */
16
+ const PLACEHOLDER_OPACITY = 0.62;
17
+ /** Placeholder text over an empty document. */
18
+ export class Placeholder {
19
+ #element;
20
+ /**
21
+ * A placeholder inside `parent` in the body text's CSS `font` and
22
+ * `color`.
23
+ */
24
+ constructor(parent, font, color) {
25
+ this.#element = parent.ownerDocument.createElement("div");
26
+ this.#element.className = "cv-placeholder";
27
+ this.#element.setAttribute("aria-hidden", "true");
28
+ Object.assign(this.#element.style, {
29
+ position: "absolute",
30
+ display: "none",
31
+ margin: "0",
32
+ font,
33
+ color,
34
+ opacity: String(PLACEHOLDER_OPACITY),
35
+ whiteSpace: "pre",
36
+ overflow: "hidden",
37
+ pointerEvents: "none",
38
+ userSelect: "none",
39
+ });
40
+ parent.append(this.#element);
41
+ }
42
+ /** The text to show. An empty string shows nothing. */
43
+ set text(text) {
44
+ this.#element.textContent = text;
45
+ }
46
+ /**
47
+ * Show the placeholder on the line `line` covers, in document
48
+ * coordinates, or hide it for `null`.
49
+ */
50
+ show(line) {
51
+ const style = this.#element.style;
52
+ if (line === null || this.#element.textContent === "") {
53
+ style.display = "none";
54
+ return;
55
+ }
56
+ // The caret is centred on where text starts.
57
+ const left = line.x + line.width / 2;
58
+ Object.assign(style, {
59
+ display: "block",
60
+ left: `${left}px`,
61
+ top: `${line.y}px`,
62
+ right: "0",
63
+ lineHeight: `${line.height}px`,
64
+ });
65
+ }
66
+ dispose() {
67
+ this.#element.remove();
68
+ }
69
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Which modifier `Mod` means in key bindings.
3
+ *
4
+ * @module
5
+ */
6
+ /** Which modifier `Mod` means in key bindings: Cmd on Apple platforms, Ctrl elsewhere. */
7
+ export type KeyPlatform = "apple" | "other";
8
+ /** Whether `navigator` belongs to an Apple platform, where `Mod` is Cmd. */
9
+ export declare function detectPlatform(navigator: Navigator): KeyPlatform;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Which modifier `Mod` means in key bindings.
3
+ *
4
+ * @module
5
+ */
6
+ /** Whether `navigator` belongs to an Apple platform, where `Mod` is Cmd. */
7
+ export function detectPlatform(navigator) {
8
+ const data = navigator.userAgentData;
9
+ return /Mac|iPhone|iPad|iPod/i.test(data?.platform ?? navigator.platform)
10
+ ? "apple"
11
+ : "other";
12
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Plugins: optional features with their own typed API on the editor.
3
+ *
4
+ * A plugin is a name and a `setup` function. Whatever `setup` returns
5
+ * becomes `editor.plugins[name]`, typed from the plugin itself, so using a
6
+ * plugin's API without registering it is a type error, and code for an
7
+ * unregistered plugin is never loaded.
8
+ *
9
+ * @module
10
+ */
11
+ import type { CanvasMeasurer } from "./measure.ts";
12
+ /** What the editor offers a plugin while it sets up. */
13
+ export interface PluginContext {
14
+ /** The element the editor renders into. */
15
+ readonly element: HTMLElement;
16
+ /** The measurer, to hear which fonts and text each relayout measured. */
17
+ readonly measurer: CanvasMeasurer;
18
+ /** Aborted when the editor is destroyed; release listeners and timers with it. */
19
+ readonly signal: AbortSignal;
20
+ /** Faces of `families` finished loading: re-measure only what uses them. */
21
+ fontsLoaded(families: readonly string[]): void;
22
+ /** Lay out text in `families` without drawing it, as during a `font-display` block period. */
23
+ hideFamilies(families: readonly string[]): void;
24
+ /** Paint again on the next frame. */
25
+ invalidate(): void;
26
+ /** Delay the editor's `ready()` until `work` settles. */
27
+ waitFor(work: Promise<unknown>): void;
28
+ }
29
+ /** A plugin named `Name` whose API is `Api`. */
30
+ export interface Plugin<Name extends string = string, Api = unknown> {
31
+ readonly name: Name;
32
+ setup(context: PluginContext): Api;
33
+ }
34
+ /** A list of plugins. */
35
+ export type Plugins = readonly Plugin[];
36
+ /** Each registered plugin's API, by name. */
37
+ export type PluginApis<P extends Plugins> = {
38
+ readonly [Entry in P[number] as Entry["name"]]: ReturnType<Entry["setup"]>;
39
+ };
package/dist/plugin.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,87 @@
1
1
  {
2
- "name": "@canvist/web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@canvist/web",
3
+ "version": "0.2.0",
4
+ "description": "The canvist editor for the browser: a canvas that wraps text exactly like canvist's server-rendered HTML, with typed commands and a font plugin.",
5
+ "license": "Unlicense",
6
+ "homepage": "https://github.com/canvist/canvist/tree/main/packages/canvist-web#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/canvist/canvist.git",
10
+ "directory": "packages/canvist-web"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/canvist/canvist/issues",
14
+ "email": "support@canvist.dev"
15
+ },
16
+ "type": "module",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/mod.d.ts",
20
+ "default": "./dist/mod.js"
21
+ },
22
+ "./fonts": {
23
+ "types": "./dist/fonts.d.ts",
24
+ "default": "./dist/fonts.js"
25
+ },
26
+ "./menus": {
27
+ "types": "./dist/menus.d.ts",
28
+ "default": "./dist/menus.js"
29
+ },
30
+ "./collab": {
31
+ "types": "./dist/collab.d.ts",
32
+ "default": "./dist/collab.js"
33
+ },
34
+ "./full": {
35
+ "types": "./dist/full.d.ts",
36
+ "default": "./dist/full.js"
37
+ },
38
+ "./wasm/canvist_web_bg.wasm": "./wasm/canvist_web_bg.wasm",
39
+ "./wasm-collab/canvist_web_bg.wasm": "./wasm-collab/canvist_web_bg.wasm",
40
+ "./wasm-full/canvist_web_bg.wasm": "./wasm-full/canvist_web_bg.wasm"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "wasm/*.js",
45
+ "wasm/*.d.ts",
46
+ "wasm/*.wasm",
47
+ "wasm-collab/*.js",
48
+ "wasm-collab/*.d.ts",
49
+ "wasm-collab/*.wasm",
50
+ "wasm-full/*.js",
51
+ "wasm-full/*.d.ts",
52
+ "wasm-full/*.wasm"
53
+ ],
54
+ "sideEffects": false,
55
+ "scripts": {
56
+ "build:wasm": "npm run build:wasm:core && npm run build:wasm:collab && npm run build:wasm:full",
57
+ "build:wasm:core": "cd ../.. && RUSTFLAGS=\"--cfg canvist_omit_docs\" wasm-pack build crates/canvist_web --target web --profile wasm-web --no-opt --out-dir ../../packages/canvist-web/wasm --no-pack && rm packages/canvist-web/wasm/.gitignore && cd packages/canvist-web && node scripts/optimize-wasm.ts wasm/canvist_web_bg.wasm",
58
+ "build:wasm:collab": "cd ../.. && RUSTFLAGS=\"--cfg canvist_omit_docs\" wasm-pack build crates/canvist_web --target web --profile wasm-web --no-opt --out-dir ../../packages/canvist-web/wasm-collab --no-pack -- --features collab && rm packages/canvist-web/wasm-collab/.gitignore && cd packages/canvist-web && node scripts/optimize-wasm.ts wasm-collab/canvist_web_bg.wasm",
59
+ "build:wasm:full": "cd ../.. && RUSTFLAGS=\"--cfg canvist_omit_docs\" wasm-pack build crates/canvist_web --target web --profile wasm-web --no-opt --out-dir ../../packages/canvist-web/wasm-full --no-pack -- --features full && rm packages/canvist-web/wasm-full/.gitignore && cd packages/canvist-web && node scripts/optimize-wasm.ts wasm-full/canvist_web_bg.wasm",
60
+ "check:types": "tsc --noEmit",
61
+ "test": "node --test tests/*.test.ts",
62
+ "test:browser": "playwright test",
63
+ "dev": "vite --config tests/vite.config.ts",
64
+ "build": "tsc -p tsconfig.build.json",
65
+ "prepack": "npm run build",
66
+ "size": "vite build --config tests/bundle/vite.config.ts --logLevel error && node scripts/bundle-size.ts --budget 359000"
67
+ },
68
+ "dependencies": {
69
+ "@canvist/headless": "^0.2.0"
70
+ },
71
+ "devDependencies": {
72
+ "@axe-core/playwright": "^4.14.0",
73
+ "@guidepup/virtual-screen-reader": "^0.33.0",
74
+ "@playwright/test": "^1.58.0",
75
+ "@types/node": "^24.0.0",
76
+ "@types/pngjs": "^6.0.5",
77
+ "binaryen": "^132.0.0",
78
+ "pixelmatch": "^7.2.0",
79
+ "pngjs": "^7.0.0",
80
+ "ssim.js": "^3.5.0",
81
+ "subset-font": "^2.9.0",
82
+ "typescript": "^7.0.2",
83
+ "vite": "^8.0.0",
84
+ "y-websocket": "^2.1.0",
85
+ "yjs": "^13.6.33"
86
+ }
87
+ }
package/readme.md ADDED
@@ -0,0 +1,92 @@
1
+ <p align="center">
2
+ <a href="https://github.com/canvist/canvist">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/canvist/canvist/main/assets/brand/vector/lockup-dark.svg">
5
+ <img width="220" src="https://raw.githubusercontent.com/canvist/canvist/main/assets/brand/vector/lockup.svg" alt="canvist" />
6
+ </picture>
7
+ </a>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://github.com/canvist/canvist/blob/main/docs/src/quick-start/web.md"><strong>Quick start</strong></a> ·
12
+ <a href="https://github.com/canvist/canvist/blob/main/docs/src/readme.md"><strong>Docs</strong></a> ·
13
+ <a href="https://github.com/canvist/canvist/blob/main/docs/src/reference/contributing.md"><strong>Contributing</strong></a> ·
14
+ <a href="https://github.com/canvist/canvist"><strong>canvist</strong></a>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://www.npmjs.com/package/@canvist/web"><img src="https://img.shields.io/npm/v/%40canvist%2Fweb?color=3B2CF5" alt="npm version" /></a>
19
+ <a href="https://github.com/canvist/canvist/actions/workflows/web.yml"><img src="https://github.com/canvist/canvist/actions/workflows/web.yml/badge.svg?branch=main" alt="web status" /></a>
20
+ <a href="https://unlicense.org"><img src="https://img.shields.io/badge/license-Unlicense-3B2CF5" alt="Unlicense" /></a>
21
+ </p>
22
+
23
+ # @canvist/web
24
+
25
+ The canvist editor for the browser. Every pixel of the document is painted on a canvas, but the browser measures the text, so the canvas wraps each line exactly where canvist's server-rendered HTML does and hydrating moves nothing.
26
+
27
+ ```sh
28
+ npm install @canvist/web
29
+ ```
30
+
31
+ ```ts
32
+ import { createEditor } from "@canvist/web";
33
+
34
+ const editor = await createEditor(document.querySelector("#editor")!, {
35
+ content: { format: "markdown", input: "Hello **world**" },
36
+ });
37
+
38
+ editor.select({ path: [0], offset: 0 }, { path: [0], offset: 5 });
39
+ editor.commands.toggleItalic();
40
+ editor.toHtml(); // what a server would send: { html, css }
41
+ ```
42
+
43
+ Using React or Vue? See [`@canvist/react`](../canvist-react) and [`@canvist/vue`](../canvist-vue). Rendering on a server? See [`@canvist/headless/ssr`](../../docs/src/quick-start/ssr.md).
44
+
45
+ ## What it does
46
+
47
+ - **Editing like TipTap:** typed commands and queries, `editable: false` for read-only, a `placeholder`, and menus that follow the selection from `@canvist/web/menus` (`bubbleMenu`, `floatingMenu`).
48
+ - **Collaboration:** `@canvist/web/collab` joins a room on any y-websocket server with `connect(editor, { url, room })`, with per-person undo. Its Yjs build is a separate module, so editors that don't collaborate never download it. `editor.caretRectAt(position)` places other people's carets.
49
+ - **Every extension:** `createEditor` from `@canvist/web/full` takes every extension canvist ships, from tables to math, in a larger module that StarterKit editors never download.
50
+ - **Server rendering:** an element holding canvist's server-rendered HTML hydrates without a layout shift.
51
+ - **Small:** 349.6 KB gzipped for the editor and the StarterKit, with a 350 KB budget CI enforces.
52
+
53
+ ## Types follow what you register
54
+
55
+ Commands, queries, and document JSON are typed from the extensions you pass, TipTap's StarterKit by default. Plugins add their own API under `editor.plugins`:
56
+
57
+ ```ts
58
+ import { bold, createEditor, heading } from "@canvist/web";
59
+ import { fonts } from "@canvist/web/fonts";
60
+
61
+ const editor = await createEditor(element, {
62
+ extensions: [bold, heading],
63
+ plugins: [fonts()],
64
+ });
65
+
66
+ editor.commands.setHeading({ level: 2 });
67
+ editor.commands.toggleItalic(); // type error: italic is not registered
68
+ await editor.plugins.fonts.local(); // installed fonts, where the browser allows it
69
+ ```
70
+
71
+ ## Built for real typing
72
+
73
+ - **Fonts:** installed fonts and the page's web fonts work with no setup. `@canvist/web/fonts` loads faces the way `@font-face` does and adds metric-compatible fallbacks, so a font swap barely moves anything. See [Fonts and text rendering](../../docs/src/guides/fonts.md).
74
+ - **Input:** `EditContext` in Chromium and a hidden textarea elsewhere, so input methods, dictation, and autocorrect work as they do in a text field. See [Text input on the web](../../docs/src/guides/web-input.md).
75
+ - **Accessibility:** a visually hidden copy of the document lets screen readers read it, and every change runs an axe audit and screen reader smoke tests.
76
+ - **Latency:** every input event is painted in its own task, and `editor.inputLatency()` reports how long that took.
77
+
78
+ <details>
79
+ <summary><strong>Development</strong></summary>
80
+
81
+ ```sh
82
+ npm ci
83
+ npm run build:wasm # builds crates/canvist_web into wasm/
84
+ npx tsc --noEmit
85
+ npm test # unit tests in Node
86
+ npx playwright test # parity with server-rendered HTML, editing, and input latency in Chromium, Firefox, and WebKit
87
+ node scripts/parity-report.ts
88
+ ```
89
+
90
+ The parity tests and their thresholds are described in [Rendering parity](../../docs/src/guides/rendering-parity.md).
91
+
92
+ </details>