@canvist/web 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/collab.d.ts +99 -0
- package/dist/collab.js +211 -0
- package/dist/edit-context.d.ts +59 -0
- package/dist/edit-context.js +16 -0
- package/dist/editor.d.ts +301 -0
- package/dist/editor.js +776 -0
- package/dist/font-watcher.d.ts +28 -0
- package/dist/font-watcher.js +88 -0
- package/dist/fonts.d.ts +130 -0
- package/dist/fonts.js +343 -0
- package/dist/full.d.ts +40 -0
- package/dist/full.js +25 -0
- package/dist/input.d.ts +68 -0
- package/dist/input.js +551 -0
- package/dist/load.d.ts +9 -0
- package/dist/load.js +21 -0
- package/dist/measure.d.ts +56 -0
- package/dist/measure.js +524 -0
- package/dist/menus.d.ts +71 -0
- package/dist/menus.js +173 -0
- package/dist/mirror.d.ts +24 -0
- package/dist/mirror.js +73 -0
- package/dist/mod.d.ts +30 -0
- package/dist/mod.js +7 -0
- package/dist/placeholder.d.ts +27 -0
- package/dist/placeholder.js +69 -0
- package/dist/platform.d.ts +9 -0
- package/dist/platform.js +12 -0
- package/dist/plugin.d.ts +39 -0
- package/dist/plugin.js +1 -0
- package/package.json +86 -5
- package/readme.md +92 -0
- package/wasm/canvist_web.d.ts +358 -0
- package/wasm/canvist_web.js +1175 -0
- package/wasm/canvist_web_bg.wasm +0 -0
- package/wasm/canvist_web_bg.wasm.d.ts +59 -0
- package/wasm-collab/canvist_web.d.ts +391 -0
- package/wasm-collab/canvist_web.js +1343 -0
- package/wasm-collab/canvist_web_bg.wasm +0 -0
- package/wasm-collab/canvist_web_bg.wasm.d.ts +64 -0
- package/wasm-full/canvist_web.d.ts +358 -0
- package/wasm-full/canvist_web.js +1175 -0
- package/wasm-full/canvist_web_bg.wasm +0 -0
- package/wasm-full/canvist_web_bg.wasm.d.ts +59 -0
- package/README.md +0 -3
package/dist/input.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input: the keyboard, IME composition, the pointer, the clipboard, and
|
|
3
|
+
* drag and drop, turned into editor changes.
|
|
4
|
+
*
|
|
5
|
+
* Text arrives through an `EditContext` attached to the canvas where the
|
|
6
|
+
* browser has one, so input methods see the paragraph being edited and
|
|
7
|
+
* place their windows at the caret. Elsewhere, or when the `input` option
|
|
8
|
+
* forces it, a transparent textarea at the caret takes the focus and the
|
|
9
|
+
* text. Either way, key bindings run first and every change is painted in
|
|
10
|
+
* the same task as its event.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import type { WebEditor } from "../wasm/canvist_web.js";
|
|
15
|
+
/**
|
|
16
|
+
* How the editor receives text: through `EditContext` where the browser
|
|
17
|
+
* has it (`"auto"`), always through it, or always through a hidden
|
|
18
|
+
* textarea. `"textarea"` is the kill switch for EditContext bugs.
|
|
19
|
+
*/
|
|
20
|
+
export type InputMode = "auto" | "editcontext" | "textarea";
|
|
21
|
+
/** What the input layer needs from the editor that owns it. */
|
|
22
|
+
export interface InputHost {
|
|
23
|
+
readonly element: HTMLElement;
|
|
24
|
+
readonly canvas: HTMLCanvasElement;
|
|
25
|
+
readonly wasm: WebEditor;
|
|
26
|
+
readonly signal: AbortSignal;
|
|
27
|
+
/** An accessible name for the text field. */
|
|
28
|
+
readonly label: string;
|
|
29
|
+
/** The document coordinate at the canvas's top edge. */
|
|
30
|
+
viewportTop(): number;
|
|
31
|
+
/**
|
|
32
|
+
* Input changed the document or selection: paint now. `since` is the
|
|
33
|
+
* input's timestamp, on the `performance.now()` clock.
|
|
34
|
+
*/
|
|
35
|
+
changed(since: number): void;
|
|
36
|
+
/** The editor gained or lost focus. */
|
|
37
|
+
focusChanged(focused: boolean): void;
|
|
38
|
+
}
|
|
39
|
+
/** Whether `mode` resolves to EditContext in this browser. */
|
|
40
|
+
export declare function usesEditContext(mode: InputMode): boolean;
|
|
41
|
+
/** Keyboard, text, pointer, clipboard, and drop input for one editor. */
|
|
42
|
+
export declare class Input {
|
|
43
|
+
#private;
|
|
44
|
+
constructor(host: InputHost, mode: InputMode);
|
|
45
|
+
/** Which input path is in use. */
|
|
46
|
+
get mode(): "editcontext" | "textarea";
|
|
47
|
+
/** The element that takes focus. */
|
|
48
|
+
get target(): HTMLElement;
|
|
49
|
+
focus(): void;
|
|
50
|
+
/**
|
|
51
|
+
* Accept text input, or make the editor read-only: the selection still
|
|
52
|
+
* moves with the pointer and navigation keys, and copying works, but
|
|
53
|
+
* nothing typed, pasted, cut, or dropped changes the document.
|
|
54
|
+
*/
|
|
55
|
+
setEditable(editable: boolean): void;
|
|
56
|
+
/** Show `text`, dimmed, while the document is empty; an empty string shows none. */
|
|
57
|
+
setPlaceholder(text: string): void;
|
|
58
|
+
/** Remove the textarea or detach the EditContext. Listeners go with the host's signal. */
|
|
59
|
+
dispose(): void;
|
|
60
|
+
/**
|
|
61
|
+
* Tell the platform what changed outside its own input: the paragraph
|
|
62
|
+
* and selection for an EditContext, and where the caret is. Left alone
|
|
63
|
+
* during a composition, which the input method owns.
|
|
64
|
+
*/
|
|
65
|
+
sync(): void;
|
|
66
|
+
/** Tell the platform where the editor and caret are on screen, for IME windows. */
|
|
67
|
+
syncBounds(): void;
|
|
68
|
+
}
|
package/dist/input.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { editContextApi, } from "./edit-context.js";
|
|
2
|
+
import { detectPlatform } from "./platform.js";
|
|
3
|
+
/** Modifier bits `WebEditor.keyDown` takes. */
|
|
4
|
+
const SHIFT = 1;
|
|
5
|
+
const CTRL = 2;
|
|
6
|
+
const ALT = 4;
|
|
7
|
+
const META = 8;
|
|
8
|
+
/**
|
|
9
|
+
* Keys that only move the selection, which a read-only editor still
|
|
10
|
+
* handles (with any modifiers) so people can select text to copy.
|
|
11
|
+
*/
|
|
12
|
+
const NAVIGATION_KEYS = new Set([
|
|
13
|
+
"ArrowLeft",
|
|
14
|
+
"ArrowRight",
|
|
15
|
+
"ArrowUp",
|
|
16
|
+
"ArrowDown",
|
|
17
|
+
"Home",
|
|
18
|
+
"End",
|
|
19
|
+
"PageUp",
|
|
20
|
+
"PageDown",
|
|
21
|
+
]);
|
|
22
|
+
/** Presses this close in time (ms) and space (CSS px) count as one multi-click. */
|
|
23
|
+
const MULTI_CLICK_MS = 500;
|
|
24
|
+
const MULTI_CLICK_SLOP = 4;
|
|
25
|
+
/** Whether `mode` resolves to EditContext in this browser. */
|
|
26
|
+
export function usesEditContext(mode) {
|
|
27
|
+
return mode !== "textarea" && editContextApi() !== undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Keyboard, text, pointer, clipboard, and drop input for one editor. */
|
|
30
|
+
export class Input {
|
|
31
|
+
#host;
|
|
32
|
+
#context;
|
|
33
|
+
#textarea;
|
|
34
|
+
/** The element that holds focus while the editor is focused. */
|
|
35
|
+
#target;
|
|
36
|
+
#composing = false;
|
|
37
|
+
/** The preedit text of the composition in progress. */
|
|
38
|
+
#preedit = "";
|
|
39
|
+
/** Where the composition starts in the EditContext's text. */
|
|
40
|
+
#compositionFrom = 0;
|
|
41
|
+
/** The pointer is pressed and selecting. */
|
|
42
|
+
#pressed = false;
|
|
43
|
+
/** This editor's selection is being dragged. */
|
|
44
|
+
#dragging = false;
|
|
45
|
+
#droppedHere = false;
|
|
46
|
+
#editable = true;
|
|
47
|
+
#lastPress = { count: 0, time: -Infinity, x: 0, y: 0 };
|
|
48
|
+
/**
|
|
49
|
+
* When the last key without a binding went down: the text it types
|
|
50
|
+
* arrives in a later event whose timestamp is its own dispatch, so
|
|
51
|
+
* latency is measured from the key.
|
|
52
|
+
*/
|
|
53
|
+
#keyTime;
|
|
54
|
+
constructor(host, mode) {
|
|
55
|
+
this.#host = host;
|
|
56
|
+
const EditContextApi = mode === "textarea" ? undefined : editContextApi();
|
|
57
|
+
if (EditContextApi === undefined) {
|
|
58
|
+
this.#textarea = createTextarea(host.element.ownerDocument);
|
|
59
|
+
// Right after the canvas, where EditContext's text field is, so
|
|
60
|
+
// screen readers meet the field before the document's mirror.
|
|
61
|
+
host.canvas.after(this.#textarea);
|
|
62
|
+
this.#target = this.#textarea;
|
|
63
|
+
this.#listenToTextarea(this.#textarea);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
this.#context = new EditContextApi();
|
|
67
|
+
const canvas = host.canvas;
|
|
68
|
+
canvas.tabIndex = 0;
|
|
69
|
+
canvas.editContext = this.#context;
|
|
70
|
+
this.#target = canvas;
|
|
71
|
+
this.#listenToEditContext(this.#context);
|
|
72
|
+
}
|
|
73
|
+
this.#target.setAttribute("role", "textbox");
|
|
74
|
+
this.#target.setAttribute("aria-multiline", "true");
|
|
75
|
+
this.#target.setAttribute("aria-label", host.label);
|
|
76
|
+
this.#listen();
|
|
77
|
+
}
|
|
78
|
+
/** Which input path is in use. */
|
|
79
|
+
get mode() {
|
|
80
|
+
return this.#context === undefined ? "textarea" : "editcontext";
|
|
81
|
+
}
|
|
82
|
+
/** The element that takes focus. */
|
|
83
|
+
get target() {
|
|
84
|
+
return this.#target;
|
|
85
|
+
}
|
|
86
|
+
focus() {
|
|
87
|
+
this.#target.focus({ preventScroll: true });
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Accept text input, or make the editor read-only: the selection still
|
|
91
|
+
* moves with the pointer and navigation keys, and copying works, but
|
|
92
|
+
* nothing typed, pasted, cut, or dropped changes the document.
|
|
93
|
+
*/
|
|
94
|
+
setEditable(editable) {
|
|
95
|
+
if (editable === this.#editable) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.#editable = editable;
|
|
99
|
+
this.#target.setAttribute("aria-readonly", String(!editable));
|
|
100
|
+
if (this.#textarea !== undefined) {
|
|
101
|
+
// A read-only textarea takes focus but no text or composition.
|
|
102
|
+
this.#textarea.readOnly = !editable;
|
|
103
|
+
}
|
|
104
|
+
if (this.#context !== undefined) {
|
|
105
|
+
const canvas = this.#host.canvas;
|
|
106
|
+
canvas.editContext = editable ? this.#context : null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Show `text`, dimmed, while the document is empty; an empty string shows none. */
|
|
110
|
+
setPlaceholder(text) {
|
|
111
|
+
if (text === "") {
|
|
112
|
+
this.#target.removeAttribute("aria-placeholder");
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
this.#target.setAttribute("aria-placeholder", text);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Remove the textarea or detach the EditContext. Listeners go with the host's signal. */
|
|
119
|
+
dispose() {
|
|
120
|
+
this.#textarea?.remove();
|
|
121
|
+
if (this.#context !== undefined) {
|
|
122
|
+
this.#host.canvas.editContext =
|
|
123
|
+
null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Tell the platform what changed outside its own input: the paragraph
|
|
128
|
+
* and selection for an EditContext, and where the caret is. Left alone
|
|
129
|
+
* during a composition, which the input method owns.
|
|
130
|
+
*/
|
|
131
|
+
sync() {
|
|
132
|
+
if (this.#composing) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const context = this.#context;
|
|
136
|
+
if (context !== undefined) {
|
|
137
|
+
const [text = "", start = 0, end = 0] = this.#host.wasm
|
|
138
|
+
.editContextState();
|
|
139
|
+
if (context.text !== text) {
|
|
140
|
+
context.updateText(0, context.text.length, text);
|
|
141
|
+
}
|
|
142
|
+
if (context.selectionStart !== start || context.selectionEnd !== end) {
|
|
143
|
+
context.updateSelection(start, end);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this.syncBounds();
|
|
147
|
+
}
|
|
148
|
+
/** Tell the platform where the editor and caret are on screen, for IME windows. */
|
|
149
|
+
syncBounds() {
|
|
150
|
+
const caret = this.#caretClientRect();
|
|
151
|
+
if (this.#context !== undefined) {
|
|
152
|
+
this.#context.updateControlBounds(this.#host.element.getBoundingClientRect());
|
|
153
|
+
if (caret !== undefined) {
|
|
154
|
+
this.#context.updateSelectionBounds(caret);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (this.#textarea !== undefined && caret !== undefined) {
|
|
158
|
+
const element = this.#host.element.getBoundingClientRect();
|
|
159
|
+
Object.assign(this.#textarea.style, {
|
|
160
|
+
left: `${caret.x - element.left + this.#host.element.scrollLeft}px`,
|
|
161
|
+
top: `${caret.y - element.top + this.#host.element.scrollTop}px`,
|
|
162
|
+
height: `${caret.height}px`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
#listen() {
|
|
167
|
+
const { canvas, element, signal } = this.#host;
|
|
168
|
+
const target = this.#target;
|
|
169
|
+
target.addEventListener("keydown", (event) => this.#keyDown(event), {
|
|
170
|
+
signal,
|
|
171
|
+
});
|
|
172
|
+
target.addEventListener("focus", () => this.#host.focusChanged(true), {
|
|
173
|
+
signal,
|
|
174
|
+
});
|
|
175
|
+
target.addEventListener("blur", () => this.#host.focusChanged(false), {
|
|
176
|
+
signal,
|
|
177
|
+
});
|
|
178
|
+
canvas.addEventListener("pointerdown", (event) => this.#pointerDown(event), {
|
|
179
|
+
signal,
|
|
180
|
+
});
|
|
181
|
+
canvas.addEventListener("pointermove", (event) => this.#pointerMove(event), {
|
|
182
|
+
signal,
|
|
183
|
+
});
|
|
184
|
+
canvas.addEventListener("pointerup", (event) => this.#pointerUp(event), {
|
|
185
|
+
signal,
|
|
186
|
+
});
|
|
187
|
+
canvas.addEventListener("pointercancel", () => this.#release(), { signal });
|
|
188
|
+
element.addEventListener("copy", (event) => this.#copy(event, false), {
|
|
189
|
+
signal,
|
|
190
|
+
});
|
|
191
|
+
element.addEventListener("cut", (event) => this.#copy(event, true), {
|
|
192
|
+
signal,
|
|
193
|
+
});
|
|
194
|
+
element.addEventListener("paste", (event) => this.#paste(event), {
|
|
195
|
+
signal,
|
|
196
|
+
});
|
|
197
|
+
canvas.addEventListener("dragstart", (event) => this.#dragStart(event), {
|
|
198
|
+
signal,
|
|
199
|
+
});
|
|
200
|
+
canvas.addEventListener("dragend", (event) => this.#dragEnd(event), {
|
|
201
|
+
signal,
|
|
202
|
+
});
|
|
203
|
+
canvas.addEventListener("dragover", (event) => this.#dragOver(event), {
|
|
204
|
+
signal,
|
|
205
|
+
});
|
|
206
|
+
canvas.addEventListener("drop", (event) => this.#drop(event), { signal });
|
|
207
|
+
}
|
|
208
|
+
#listenToEditContext(context) {
|
|
209
|
+
const signal = this.#host.signal;
|
|
210
|
+
context.addEventListener("textupdate", (event) => this.#textUpdate(event), {
|
|
211
|
+
signal,
|
|
212
|
+
});
|
|
213
|
+
context.addEventListener("compositionstart", () => {
|
|
214
|
+
this.#composing = true;
|
|
215
|
+
this.#preedit = "";
|
|
216
|
+
this.#compositionFrom = Math.min(context.selectionStart, context.selectionEnd);
|
|
217
|
+
}, { signal });
|
|
218
|
+
context.addEventListener("compositionend", (event) => {
|
|
219
|
+
this.#composing = false;
|
|
220
|
+
this.#edit(event, (wasm) => wasm.commitComposition(this.#preedit));
|
|
221
|
+
this.#preedit = "";
|
|
222
|
+
}, { signal });
|
|
223
|
+
context.addEventListener("characterboundsupdate", (event) => this.#characterBounds(context, event), { signal });
|
|
224
|
+
}
|
|
225
|
+
#listenToTextarea(textarea) {
|
|
226
|
+
const signal = this.#host.signal;
|
|
227
|
+
textarea.addEventListener("beforeinput", (event) => this.#beforeInput(event), {
|
|
228
|
+
signal,
|
|
229
|
+
});
|
|
230
|
+
textarea.addEventListener("compositionstart", () => {
|
|
231
|
+
this.#composing = true;
|
|
232
|
+
}, { signal });
|
|
233
|
+
textarea.addEventListener("compositionupdate", (event) => {
|
|
234
|
+
this.#edit(event, (wasm) => wasm.setComposition(event.data));
|
|
235
|
+
}, { signal });
|
|
236
|
+
textarea.addEventListener("compositionend", (event) => {
|
|
237
|
+
this.#composing = false;
|
|
238
|
+
this.#edit(event, (wasm) => wasm.commitComposition(event.data));
|
|
239
|
+
textarea.value = "";
|
|
240
|
+
}, { signal });
|
|
241
|
+
// Whatever the browser put in the textarea has been applied already.
|
|
242
|
+
textarea.addEventListener("input", () => {
|
|
243
|
+
if (!this.#composing) {
|
|
244
|
+
textarea.value = "";
|
|
245
|
+
}
|
|
246
|
+
}, { signal });
|
|
247
|
+
}
|
|
248
|
+
/** Run the key binding for `event`; keys without one reach the browser as text. */
|
|
249
|
+
#keyDown(event) {
|
|
250
|
+
// A key that an input method is handling (`Process`, key code 229)
|
|
251
|
+
// belongs to the composition.
|
|
252
|
+
if (event.isComposing || this.#composing || event.keyCode === 229) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!this.#editable && !readOnlyKey(event)) {
|
|
256
|
+
// WebKit treats Backspace outside editable text as Back.
|
|
257
|
+
if (event.key === "Backspace") {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (this.#host.wasm.keyDown(event.key, modifiers(event))) {
|
|
263
|
+
event.preventDefault();
|
|
264
|
+
this.#changed(event);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
this.#keyTime = event.timeStamp;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Text input through the EditContext, composed or typed. */
|
|
271
|
+
#textUpdate(event) {
|
|
272
|
+
if (!this.#composing) {
|
|
273
|
+
this.#edit(event, (wasm) => wasm.replaceText(event.updateRangeStart, event.updateRangeEnd, event.text));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// The update replaces part of the preedit, in the context's offsets.
|
|
277
|
+
const length = this.#preedit.length;
|
|
278
|
+
const from = clamp(event.updateRangeStart - this.#compositionFrom, length);
|
|
279
|
+
const to = clamp(event.updateRangeEnd - this.#compositionFrom, length);
|
|
280
|
+
this.#preedit = this.#preedit.slice(0, from) + event.text +
|
|
281
|
+
this.#preedit.slice(to);
|
|
282
|
+
this.#edit(event, (wasm) => wasm.setComposition(this.#preedit));
|
|
283
|
+
}
|
|
284
|
+
/** Text input through the textarea: typed text and edits without a key binding (as from soft keyboards). */
|
|
285
|
+
#beforeInput(event) {
|
|
286
|
+
// Chromium still sends text input to a read-only textarea.
|
|
287
|
+
if (!this.#editable) {
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (this.#composing || event.inputType === "insertCompositionText") {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const wasm = this.#host.wasm;
|
|
295
|
+
const apply = () => {
|
|
296
|
+
switch (event.inputType) {
|
|
297
|
+
case "insertText":
|
|
298
|
+
case "insertReplacementText":
|
|
299
|
+
return wasm.insertText(event.data ?? event.dataTransfer?.getData("text/plain") ?? "");
|
|
300
|
+
case "insertLineBreak":
|
|
301
|
+
case "insertParagraph":
|
|
302
|
+
return wasm.keyDown("Enter", event.inputType === "insertLineBreak" ? SHIFT : 0);
|
|
303
|
+
case "deleteContentBackward":
|
|
304
|
+
return wasm.keyDown("Backspace", 0);
|
|
305
|
+
case "deleteContentForward":
|
|
306
|
+
return wasm.keyDown("Delete", 0);
|
|
307
|
+
case "historyUndo":
|
|
308
|
+
return wasm.undo();
|
|
309
|
+
case "historyRedo":
|
|
310
|
+
return wasm.redo();
|
|
311
|
+
default:
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
// Pastes and drops arrive as their own events.
|
|
316
|
+
if (event.inputType === "insertFromPaste" ||
|
|
317
|
+
event.inputType === "insertFromDrop") {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
if (apply()) {
|
|
322
|
+
this.#changed(event);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Where the characters an input method asks about are drawn, in client coordinates. */
|
|
326
|
+
#characterBounds(context, event) {
|
|
327
|
+
const flat = this.#host.wasm.characterBounds(event.rangeStart, event.rangeEnd);
|
|
328
|
+
const rects = [];
|
|
329
|
+
for (let index = 0; index + 3 < flat.length; index += 4) {
|
|
330
|
+
rects.push(this.#clientRect(flat[index] ?? 0, flat[index + 1] ?? 0, flat[index + 2] ?? 0, flat[index + 3] ?? 0));
|
|
331
|
+
}
|
|
332
|
+
context.updateCharacterBounds(event.rangeStart, rects);
|
|
333
|
+
}
|
|
334
|
+
#pointerDown(event) {
|
|
335
|
+
if (event.button !== 0) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const [x, y] = this.#documentPoint(event);
|
|
339
|
+
const wasm = this.#host.wasm;
|
|
340
|
+
const canvas = this.#host.canvas;
|
|
341
|
+
const clicks = this.#count(event);
|
|
342
|
+
// A press inside the selection may start dragging it: let the
|
|
343
|
+
// browser decide, and place the caret on release if it doesn't.
|
|
344
|
+
if (clicks === 1 && !event.shiftKey && wasm.isInSelection(x, y)) {
|
|
345
|
+
canvas.draggable = true;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
canvas.draggable = false;
|
|
349
|
+
// Keep focus in the textarea rather than letting the press blur it.
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
this.focus();
|
|
352
|
+
this.#pressed = true;
|
|
353
|
+
canvas.setPointerCapture(event.pointerId);
|
|
354
|
+
this.#edit(event, (editor) => editor.pointerDown(x, y, event.shiftKey, clicks));
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* How many clicks `event` makes in a row. Pointer events don't count
|
|
358
|
+
* clicks in every browser, so presses close in time and place are
|
|
359
|
+
* counted here when `detail` is zero.
|
|
360
|
+
*/
|
|
361
|
+
#count(event) {
|
|
362
|
+
const last = this.#lastPress;
|
|
363
|
+
const near = Math.abs(event.clientX - last.x) <= MULTI_CLICK_SLOP &&
|
|
364
|
+
Math.abs(event.clientY - last.y) <= MULTI_CLICK_SLOP &&
|
|
365
|
+
event.timeStamp - last.time <= MULTI_CLICK_MS;
|
|
366
|
+
const count = event.detail > 0 ? event.detail : near ? last.count + 1 : 1;
|
|
367
|
+
this.#lastPress = {
|
|
368
|
+
count,
|
|
369
|
+
time: event.timeStamp,
|
|
370
|
+
x: event.clientX,
|
|
371
|
+
y: event.clientY,
|
|
372
|
+
};
|
|
373
|
+
return count;
|
|
374
|
+
}
|
|
375
|
+
#pointerMove(event) {
|
|
376
|
+
if (!this.#pressed) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const [x, y] = this.#documentPoint(event);
|
|
380
|
+
this.#edit(event, (wasm) => wasm.pointerMove(x, y));
|
|
381
|
+
}
|
|
382
|
+
#pointerUp(event) {
|
|
383
|
+
const canvas = this.#host.canvas;
|
|
384
|
+
// A press inside the selection that didn't become a drag is a click.
|
|
385
|
+
if (canvas.draggable && !this.#dragging) {
|
|
386
|
+
canvas.draggable = false;
|
|
387
|
+
const [x, y] = this.#documentPoint(event);
|
|
388
|
+
this.focus();
|
|
389
|
+
this.#edit(event, (wasm) => wasm.pointerDown(x, y, false, 1));
|
|
390
|
+
}
|
|
391
|
+
this.#release();
|
|
392
|
+
}
|
|
393
|
+
#release() {
|
|
394
|
+
this.#pressed = false;
|
|
395
|
+
this.#host.wasm.pointerUp();
|
|
396
|
+
}
|
|
397
|
+
#copy(event, requestedCut) {
|
|
398
|
+
const wasm = this.#host.wasm;
|
|
399
|
+
// Cutting from a read-only document copies.
|
|
400
|
+
const cut = requestedCut && this.#editable;
|
|
401
|
+
const [html, text] = cut ? wasm.cutSelection() : wasm.copySelection();
|
|
402
|
+
if (html === undefined || text === undefined || event.clipboardData === null) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
event.preventDefault();
|
|
406
|
+
event.clipboardData.setData("text/html", html);
|
|
407
|
+
event.clipboardData.setData("text/plain", text);
|
|
408
|
+
if (cut) {
|
|
409
|
+
this.#changed(event);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
#paste(event) {
|
|
413
|
+
const data = event.clipboardData;
|
|
414
|
+
if (data === null || !this.#editable) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
event.preventDefault();
|
|
418
|
+
const html = data.getData("text/html");
|
|
419
|
+
this.#edit(event, (wasm) => html === ""
|
|
420
|
+
? wasm.pasteText(data.getData("text/plain"))
|
|
421
|
+
: wasm.pasteHtml(html));
|
|
422
|
+
}
|
|
423
|
+
#dragStart(event) {
|
|
424
|
+
const [html, text] = this.#host.wasm.copySelection();
|
|
425
|
+
if (html === undefined || text === undefined || event.dataTransfer === null) {
|
|
426
|
+
event.preventDefault();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
this.#dragging = true;
|
|
430
|
+
this.#droppedHere = false;
|
|
431
|
+
event.dataTransfer.setData("text/html", html);
|
|
432
|
+
event.dataTransfer.setData("text/plain", text);
|
|
433
|
+
event.dataTransfer.effectAllowed = this.#editable ? "copyMove" : "copy";
|
|
434
|
+
}
|
|
435
|
+
#dragEnd(event) {
|
|
436
|
+
const moved = event.dataTransfer?.dropEffect === "move";
|
|
437
|
+
// Moved to another application: remove it here.
|
|
438
|
+
if (moved && !this.#droppedHere) {
|
|
439
|
+
this.#host.wasm.cutSelection();
|
|
440
|
+
this.#changed(event);
|
|
441
|
+
}
|
|
442
|
+
this.#dragging = false;
|
|
443
|
+
this.#host.canvas.draggable = false;
|
|
444
|
+
}
|
|
445
|
+
#dragOver(event) {
|
|
446
|
+
// Without `preventDefault`, the browser refuses the drop.
|
|
447
|
+
if (event.dataTransfer === null || !this.#editable) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
event.preventDefault();
|
|
451
|
+
event.dataTransfer.dropEffect = this.#dragging && !copyModifier(event)
|
|
452
|
+
? "move"
|
|
453
|
+
: "copy";
|
|
454
|
+
}
|
|
455
|
+
#drop(event) {
|
|
456
|
+
const data = event.dataTransfer;
|
|
457
|
+
if (data === null || !this.#editable) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
const [x, y] = this.#documentPoint(event);
|
|
462
|
+
const html = data.getData("text/html");
|
|
463
|
+
const moving = this.#dragging && !copyModifier(event);
|
|
464
|
+
this.#droppedHere = true;
|
|
465
|
+
this.focus();
|
|
466
|
+
this.#edit(event, (wasm) => wasm.dropAt(x, y, html === "" ? undefined : html, data.getData("text/plain"), moving));
|
|
467
|
+
}
|
|
468
|
+
/** Apply `change` and, when it changed anything, paint for `event`. */
|
|
469
|
+
#edit(event, change) {
|
|
470
|
+
if (change(this.#host.wasm)) {
|
|
471
|
+
this.#changed(event);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
#changed(event) {
|
|
475
|
+
const text = event.type === "textupdate" || event.type === "beforeinput";
|
|
476
|
+
this.#host.changed(text ? this.#keyTime ?? event.timeStamp : event.timeStamp);
|
|
477
|
+
this.#keyTime = undefined;
|
|
478
|
+
this.sync();
|
|
479
|
+
}
|
|
480
|
+
/** A pointer event's position in document coordinates. */
|
|
481
|
+
#documentPoint(event) {
|
|
482
|
+
const canvas = this.#host.canvas.getBoundingClientRect();
|
|
483
|
+
return [
|
|
484
|
+
event.clientX - canvas.left,
|
|
485
|
+
event.clientY - canvas.top + this.#host.viewportTop(),
|
|
486
|
+
];
|
|
487
|
+
}
|
|
488
|
+
/** A rectangle in document coordinates, in client coordinates. */
|
|
489
|
+
#clientRect(x, y, width, height) {
|
|
490
|
+
const canvas = this.#host.canvas.getBoundingClientRect();
|
|
491
|
+
return new DOMRect(canvas.left + x, canvas.top + y - this.#host.viewportTop(), width, height);
|
|
492
|
+
}
|
|
493
|
+
#caretClientRect() {
|
|
494
|
+
const [x, y, width, height] = this.#host.wasm.caretRect();
|
|
495
|
+
return x === undefined || y === undefined || width === undefined ||
|
|
496
|
+
height === undefined
|
|
497
|
+
? undefined
|
|
498
|
+
: this.#clientRect(x, y, width, height);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** `event`'s modifiers as `WebEditor.keyDown` takes them. */
|
|
502
|
+
function modifiers(event) {
|
|
503
|
+
return (event.shiftKey ? SHIFT : 0) | (event.ctrlKey ? CTRL : 0) |
|
|
504
|
+
(event.altKey ? ALT : 0) | (event.metaKey ? META : 0);
|
|
505
|
+
}
|
|
506
|
+
/** Whether a read-only editor handles `event`: navigation, and select all. */
|
|
507
|
+
function readOnlyKey(event) {
|
|
508
|
+
const command = detectPlatform(navigator) === "apple"
|
|
509
|
+
? event.metaKey
|
|
510
|
+
: event.ctrlKey;
|
|
511
|
+
return NAVIGATION_KEYS.has(event.key) ||
|
|
512
|
+
(command && event.key.toLowerCase() === "a");
|
|
513
|
+
}
|
|
514
|
+
/** Whether a drop copies rather than moves: Option on Apple platforms, Ctrl elsewhere. */
|
|
515
|
+
function copyModifier(event) {
|
|
516
|
+
return detectPlatform(navigator) === "apple" ? event.altKey : event.ctrlKey;
|
|
517
|
+
}
|
|
518
|
+
function clamp(value, max) {
|
|
519
|
+
return Math.min(Math.max(value, 0), max);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* The transparent textarea that receives text where EditContext is
|
|
523
|
+
* unavailable. It follows the caret so input methods open their windows
|
|
524
|
+
* there.
|
|
525
|
+
*/
|
|
526
|
+
function createTextarea(document) {
|
|
527
|
+
const textarea = document.createElement("textarea");
|
|
528
|
+
textarea.className = "cv-input";
|
|
529
|
+
textarea.autocapitalize = "off";
|
|
530
|
+
textarea.autocomplete = "off";
|
|
531
|
+
textarea.spellcheck = false;
|
|
532
|
+
textarea.setAttribute("autocorrect", "off");
|
|
533
|
+
Object.assign(textarea.style, {
|
|
534
|
+
position: "absolute",
|
|
535
|
+
left: "0",
|
|
536
|
+
top: "0",
|
|
537
|
+
width: "1px",
|
|
538
|
+
height: "1em",
|
|
539
|
+
padding: "0",
|
|
540
|
+
border: "0",
|
|
541
|
+
margin: "0",
|
|
542
|
+
outline: "none",
|
|
543
|
+
resize: "none",
|
|
544
|
+
overflow: "hidden",
|
|
545
|
+
whiteSpace: "pre",
|
|
546
|
+
opacity: "0",
|
|
547
|
+
caretColor: "transparent",
|
|
548
|
+
pointerEvents: "none",
|
|
549
|
+
});
|
|
550
|
+
return textarea;
|
|
551
|
+
}
|
package/dist/load.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Where the WebAssembly module comes from: its bytes, a compiled module, a URL, or a response. */
|
|
2
|
+
export type WasmSource = ArrayBuffer | ArrayBufferView | WebAssembly.Module | URL | Response | Promise<Response>;
|
|
3
|
+
/**
|
|
4
|
+
* Load the WebAssembly module once. Later calls return the first call's
|
|
5
|
+
* promise, so passing a source only matters the first time. Without one, the
|
|
6
|
+
* module is fetched from next to this file, which bundlers such as Vite
|
|
7
|
+
* follow.
|
|
8
|
+
*/
|
|
9
|
+
export declare function load(source?: WasmSource): Promise<void>;
|
package/dist/load.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading the WebAssembly module, once per page: every editor shares one
|
|
3
|
+
* instance, so creating a second editor costs no extra download or compile.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import init from "../wasm/canvist_web.js";
|
|
8
|
+
let loading;
|
|
9
|
+
/**
|
|
10
|
+
* Load the WebAssembly module once. Later calls return the first call's
|
|
11
|
+
* promise, so passing a source only matters the first time. Without one, the
|
|
12
|
+
* module is fetched from next to this file, which bundlers such as Vite
|
|
13
|
+
* follow.
|
|
14
|
+
*/
|
|
15
|
+
export function load(source) {
|
|
16
|
+
loading ??= init({
|
|
17
|
+
module_or_path: source ??
|
|
18
|
+
new URL("../wasm/canvist_web_bg.wasm", import.meta.url),
|
|
19
|
+
}).then(() => undefined);
|
|
20
|
+
return loading;
|
|
21
|
+
}
|