@embedpdf/web 3.0.0-next.14 → 3.0.0-next.15
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/index.cjs +3697 -381
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2812 -137
- package/dist/index.d.ts +2812 -137
- package/dist/index.js +3570 -382
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
//#region src/file-picker.ts
|
|
2
2
|
/**
|
|
3
3
|
* A one-shot file dialog. Opens a throwaway `<input type=file>`, resolves the
|
|
4
|
-
* chosen `File`, or `null` when the dialog is dismissed.
|
|
4
|
+
* chosen `File`, or `null` when the dialog is dismissed. Must be called
|
|
5
5
|
* synchronously from a user gesture (a click) or the browser refuses to open it.
|
|
6
6
|
*
|
|
7
7
|
* A `File` is a `Blob`, which is a `BinarySource` everywhere in the stack, so the
|
|
8
8
|
* caller can hand the result straight to the engine — this module stays a pure
|
|
9
|
-
* DOM utility with no EmbedPDF types.
|
|
9
|
+
* DOM utility with no EmbedPDF types. It also holds the default provider for
|
|
10
|
+
* the annotation plugin's file prompt ({@link pickRequestedFile}), and its
|
|
11
|
+
* one install per document ({@link installFilePickerProvider}).
|
|
10
12
|
*/
|
|
11
13
|
const IMAGE_ACCEPT = "image/png,image/jpeg,application/pdf";
|
|
12
14
|
/** Open the file dialog; resolve the picked file, or null if cancelled. */
|
|
@@ -22,21 +24,16 @@ function pickImageFile(options = {}) {
|
|
|
22
24
|
const finish = (value) => {
|
|
23
25
|
if (settled) return;
|
|
24
26
|
settled = true;
|
|
25
|
-
window.removeEventListener("focus", onFocus);
|
|
26
27
|
input.remove();
|
|
27
28
|
resolve(value);
|
|
28
29
|
};
|
|
29
|
-
const onFocus = () => {
|
|
30
|
-
setTimeout(() => finish(null), 300);
|
|
31
|
-
};
|
|
32
30
|
input.addEventListener("change", () => finish(input.files?.[0] ?? null), { once: true });
|
|
33
31
|
input.addEventListener("cancel", () => finish(null), { once: true });
|
|
34
|
-
window.addEventListener("focus", onFocus, { once: true });
|
|
35
32
|
document.body.appendChild(input);
|
|
36
33
|
input.click();
|
|
37
34
|
});
|
|
38
35
|
}
|
|
39
|
-
/** The same dialog with
|
|
36
|
+
/** The same dialog with no type filter — any file (attachments). */
|
|
40
37
|
const pickFile = (options = {}) => pickImageFile({
|
|
41
38
|
accept: "*/*",
|
|
42
39
|
...options
|
|
@@ -49,34 +46,287 @@ const pickFile = (options = {}) => pickImageFile({
|
|
|
49
46
|
function saveFile(bytes, name, mimeType) {
|
|
50
47
|
const blob = bytes instanceof Blob ? bytes : new Blob([bytes], { type: mimeType ?? "application/octet-stream" });
|
|
51
48
|
const url = URL.createObjectURL(blob);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
document.body.appendChild(
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
const anchor = document.createElement("a");
|
|
50
|
+
anchor.href = url;
|
|
51
|
+
anchor.download = name;
|
|
52
|
+
anchor.style.display = "none";
|
|
53
|
+
document.body.appendChild(anchor);
|
|
54
|
+
anchor.click();
|
|
55
|
+
anchor.remove();
|
|
59
56
|
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
60
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The browser's default for a plugin's file prompt (the annotation plugin's
|
|
60
|
+
* `FilePickerProvider`): the file dialog, honouring the request's `accept`
|
|
61
|
+
* filter. A picked `File` carries its own name and type, so it goes straight
|
|
62
|
+
* through as the engine's file source; a dismissed dialog answers `null`.
|
|
63
|
+
*/
|
|
64
|
+
async function pickRequestedFile(request) {
|
|
65
|
+
const file = await pickFile({ accept: request.accept ?? "*/*" });
|
|
66
|
+
return file ? { data: file } : null;
|
|
67
|
+
}
|
|
68
|
+
/** How many mounted installs each document's port has: one is the rule. */
|
|
69
|
+
const filePickerInstalls = /* @__PURE__ */ new WeakMap();
|
|
70
|
+
/**
|
|
71
|
+
* Install `provider` as a document's file picker, until the call it returns
|
|
72
|
+
* removes it. A document has one port, so a second install while the first
|
|
73
|
+
* is there silently replaces it: `onRepeat` hears of it (an adapter warns in
|
|
74
|
+
* development, saying where to install it once).
|
|
75
|
+
*/
|
|
76
|
+
function installFilePickerProvider(annotation, provider, onRepeat) {
|
|
77
|
+
const installed = (filePickerInstalls.get(annotation) ?? 0) + 1;
|
|
78
|
+
filePickerInstalls.set(annotation, installed);
|
|
79
|
+
if (installed > 1) onRepeat();
|
|
80
|
+
const remove = annotation.setFilePickerProvider(provider);
|
|
81
|
+
return () => {
|
|
82
|
+
filePickerInstalls.set(annotation, (filePickerInstalls.get(annotation) ?? 1) - 1);
|
|
83
|
+
remove();
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/clock.ts
|
|
88
|
+
/**
|
|
89
|
+
* The browser's clock. A page without `requestAnimationFrame` (some test
|
|
90
|
+
* environments) gets a clock without frames, and plugins then skip animation.
|
|
91
|
+
*/
|
|
92
|
+
function browserClock() {
|
|
93
|
+
const clock = {
|
|
94
|
+
now: () => performance.now(),
|
|
95
|
+
after(ms, run) {
|
|
96
|
+
const timer = setTimeout(run, ms);
|
|
97
|
+
return () => clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (typeof requestAnimationFrame === "function") clock.nextFrame = (run) => {
|
|
101
|
+
const frame = requestAnimationFrame(run);
|
|
102
|
+
return () => cancelAnimationFrame(frame);
|
|
103
|
+
};
|
|
104
|
+
return clock;
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/css-text.ts
|
|
108
|
+
/**
|
|
109
|
+
* Style records as CSS text. This package hands styles out as records with
|
|
110
|
+
* camelCase names (`strokeWidth`, `fontFamily`) and lengths with their units,
|
|
111
|
+
* the shape a style object takes in React, Vue and Angular. A framework whose
|
|
112
|
+
* `style` is a string (Svelte) turns a record into CSS text with
|
|
113
|
+
* {@link cssText}: `stroke-width: …; font-family: …`. Paint values are
|
|
114
|
+
* `var(--epdf-…, …)`, which only work in `style`, never in an SVG
|
|
115
|
+
* presentation attribute.
|
|
116
|
+
*/
|
|
117
|
+
/** `strokeWidth` → `stroke-width`. */
|
|
118
|
+
const cssNameOf = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
119
|
+
/** A style record as CSS text, leaving out what is `undefined` or `null`. */
|
|
120
|
+
function cssText(style) {
|
|
121
|
+
return Object.entries(style).filter((entry) => entry[1] != null).map(([name, value]) => `${cssNameOf(name)}: ${value}`).join("; ");
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/focusable.ts
|
|
125
|
+
/**
|
|
126
|
+
* Which elements take keyboard focus, for the keyboard models (a toolbar's
|
|
127
|
+
* stops, a menu's items, a dialog's tab order). Decided from the markup only,
|
|
128
|
+
* never from layout, so it answers the same before the first paint: `hidden`,
|
|
129
|
+
* `inert` and `disabled` count; a `display: none` from a stylesheet doesn't.
|
|
130
|
+
*/
|
|
131
|
+
/** Elements that can take focus, before {@link canTakeFocus} rules out the disabled and the hidden. */
|
|
132
|
+
const FOCUSABLE_SELECTOR = [
|
|
133
|
+
"a[href]",
|
|
134
|
+
"area[href]",
|
|
135
|
+
"button",
|
|
136
|
+
"input:not([type=\"hidden\"])",
|
|
137
|
+
"select",
|
|
138
|
+
"textarea",
|
|
139
|
+
"iframe",
|
|
140
|
+
"summary",
|
|
141
|
+
"[tabindex]",
|
|
142
|
+
"[contenteditable]:not([contenteditable=\"false\"])"
|
|
143
|
+
].join(", ");
|
|
144
|
+
/** Form controls the `disabled` attribute takes focus from. */
|
|
145
|
+
const DISABLEABLE = /* @__PURE__ */ new Set([
|
|
146
|
+
"BUTTON",
|
|
147
|
+
"INPUT",
|
|
148
|
+
"SELECT",
|
|
149
|
+
"TEXTAREA",
|
|
150
|
+
"FIELDSET",
|
|
151
|
+
"OPTGROUP",
|
|
152
|
+
"OPTION"
|
|
153
|
+
]);
|
|
154
|
+
/** Whether an element can take focus now: focusable markup, not disabled, not hidden or inert. */
|
|
155
|
+
function canTakeFocus(element) {
|
|
156
|
+
if (!element.matches(FOCUSABLE_SELECTOR)) return false;
|
|
157
|
+
if (DISABLEABLE.has(element.tagName) && element.disabled) return false;
|
|
158
|
+
return element.closest("[hidden], [inert]") === null;
|
|
159
|
+
}
|
|
160
|
+
/** Whether Tab stops at an element: it takes focus and its `tabIndex` isn't negative. */
|
|
161
|
+
function isTabbable(element) {
|
|
162
|
+
return canTakeFocus(element) && element.tabIndex >= 0;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The elements `root` shows, in order: a `<slot>` that has elements assigned shows them, and what
|
|
166
|
+
* is inside them, in place of its own content. A slot in a hidden or inert part shows nothing.
|
|
167
|
+
*/
|
|
168
|
+
function shownElementsIn(root) {
|
|
169
|
+
const shown = [];
|
|
170
|
+
const visit = (parent) => {
|
|
171
|
+
for (const child of parent.children) {
|
|
172
|
+
if (child.localName === "slot") {
|
|
173
|
+
const assigned = child.assignedElements({ flatten: true });
|
|
174
|
+
if (assigned.length > 0) {
|
|
175
|
+
if (child.closest("[hidden], [inert]") !== null) continue;
|
|
176
|
+
for (const element of assigned) {
|
|
177
|
+
shown.push(element);
|
|
178
|
+
visit(element);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
shown.push(child);
|
|
184
|
+
visit(child);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
visit(root);
|
|
188
|
+
return shown;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The elements inside `root` that can take focus, in the order they show: through a `<slot>`,
|
|
192
|
+
* the page's elements the browser shows there (a web component's slotted children).
|
|
193
|
+
*/
|
|
194
|
+
function focusablesIn(root) {
|
|
195
|
+
if (root.querySelector("slot") === null) return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(canTakeFocus);
|
|
196
|
+
return shownElementsIn(root).filter((element) => element instanceof HTMLElement && canTakeFocus(element));
|
|
197
|
+
}
|
|
198
|
+
/** The elements inside `root` that Tab stops at, in document order. */
|
|
199
|
+
function tabbablesIn(root) {
|
|
200
|
+
return focusablesIn(root).filter((element) => element.tabIndex >= 0);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The element that has focus, looking into shadow roots: the document's
|
|
204
|
+
* `activeElement` is only the outermost shadow host. Null when nothing has
|
|
205
|
+
* focus (the body).
|
|
206
|
+
*/
|
|
207
|
+
function deepActiveElementOf(doc) {
|
|
208
|
+
let active = doc.activeElement;
|
|
209
|
+
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
|
210
|
+
return active === doc.body ? null : active;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Whether the element with focus is `container` or inside it, as the page shows it: through
|
|
214
|
+
* shadow roots, and from an element slotted into a shadow root to its slot, so a control
|
|
215
|
+
* slotted into a viewer counts as inside the viewer, as its keys do.
|
|
216
|
+
*/
|
|
217
|
+
function containsFocus(container) {
|
|
218
|
+
let node = deepActiveElementOf(container.ownerDocument);
|
|
219
|
+
while (node) {
|
|
220
|
+
if (node === container) return true;
|
|
221
|
+
node = node.assignedSlot ?? node.parentNode ?? node.host ?? null;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Whether a control says it can't be used now, through `disabled` or
|
|
227
|
+
* `aria-disabled="true"`: the keyboard models pass over it.
|
|
228
|
+
*/
|
|
229
|
+
function isUnavailable(element) {
|
|
230
|
+
if (DISABLEABLE.has(element.tagName) && element.disabled) return true;
|
|
231
|
+
return element.getAttribute("aria-disabled") === "true";
|
|
232
|
+
}
|
|
233
|
+
/** Input types that type text, so they keep Left, Right, Home and End for the caret. */
|
|
234
|
+
const TEXT_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
235
|
+
"",
|
|
236
|
+
"text",
|
|
237
|
+
"search",
|
|
238
|
+
"url",
|
|
239
|
+
"tel",
|
|
240
|
+
"email",
|
|
241
|
+
"password"
|
|
242
|
+
]);
|
|
243
|
+
/** Input types that change their value with the arrow keys, both ways. */
|
|
244
|
+
const STEPPED_INPUT_TYPES = /* @__PURE__ */ new Set([
|
|
245
|
+
"number",
|
|
246
|
+
"range",
|
|
247
|
+
"radio",
|
|
248
|
+
"date",
|
|
249
|
+
"datetime-local",
|
|
250
|
+
"month",
|
|
251
|
+
"time",
|
|
252
|
+
"week"
|
|
253
|
+
]);
|
|
254
|
+
/** Roles of widgets that move with the arrow keys themselves. */
|
|
255
|
+
const ARROW_ROLES = /* @__PURE__ */ new Set([
|
|
256
|
+
"slider",
|
|
257
|
+
"spinbutton",
|
|
258
|
+
"combobox",
|
|
259
|
+
"textbox",
|
|
260
|
+
"listbox",
|
|
261
|
+
"tree",
|
|
262
|
+
"grid"
|
|
263
|
+
]);
|
|
264
|
+
/**
|
|
265
|
+
* Whether a focused control uses `key` itself, so a keyboard model around it
|
|
266
|
+
* must leave the key alone: a text field moves its caret with Left, Right,
|
|
267
|
+
* Home and End; a select changes with Up and Down; a slider takes them all.
|
|
268
|
+
*/
|
|
269
|
+
function usesKey(element, key) {
|
|
270
|
+
const horizontal = key === "ArrowLeft" || key === "ArrowRight" || key === "Home" || key === "End";
|
|
271
|
+
const vertical = key === "ArrowUp" || key === "ArrowDown";
|
|
272
|
+
if (!horizontal && !vertical) return false;
|
|
273
|
+
if (element.isContentEditable || element.tagName === "TEXTAREA") return true;
|
|
274
|
+
if (element.tagName === "SELECT") return vertical;
|
|
275
|
+
if (element.tagName === "INPUT") {
|
|
276
|
+
const type = (element.getAttribute("type") ?? "").toLowerCase();
|
|
277
|
+
return STEPPED_INPUT_TYPES.has(type) || TEXT_INPUT_TYPES.has(type) && horizontal;
|
|
278
|
+
}
|
|
279
|
+
const role = element.getAttribute("role");
|
|
280
|
+
return role !== null && ARROW_ROLES.has(role);
|
|
281
|
+
}
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/text-field-event.ts
|
|
284
|
+
/** Telling a key or a copy meant for a text field from one meant for the viewer around it. */
|
|
285
|
+
/**
|
|
286
|
+
* Whether an event comes from a text field: an input, a text area, a select or editable
|
|
287
|
+
* content. Read from the event's first target, so a field inside a shadow root counts too
|
|
288
|
+
* (a listener on the document sees the shadow host as the target).
|
|
289
|
+
*/
|
|
290
|
+
function isFromTextField(event) {
|
|
291
|
+
const element = event.composedPath?.()[0] ?? event.target;
|
|
292
|
+
if (!element || !element.tagName) return false;
|
|
293
|
+
return element.tagName === "INPUT" || element.tagName === "TEXTAREA" || element.tagName === "SELECT" || element.isContentEditable;
|
|
294
|
+
}
|
|
61
295
|
//#endregion
|
|
62
296
|
//#region src/clipboard.ts
|
|
297
|
+
/**
|
|
298
|
+
* Clipboard wiring for text selection — the environmental half of copy.
|
|
299
|
+
*
|
|
300
|
+
* The selection plugin is deliberately DOM-free: its public capability ends
|
|
301
|
+
* at `readText()` (data). Everything that touches `navigator.clipboard` or
|
|
302
|
+
* the native `copy` event lives here, structurally typed so this module
|
|
303
|
+
* stays a pure DOM utility with no EmbedPDF dependencies (the selection
|
|
304
|
+
* capability satisfies {@link ClipboardSelectionSource} as-is).
|
|
305
|
+
*/
|
|
63
306
|
const PREFETCH_DEBOUNCE_MS = 150;
|
|
64
307
|
/**
|
|
65
308
|
* Wire clipboard copy for a selection. Two delivery paths, both fed by the
|
|
66
309
|
* prefetched text:
|
|
67
310
|
*
|
|
68
311
|
* - the native `copy` event (menu Edit→Copy, or ctrl/cmd+C while the page
|
|
69
|
-
* has a DOM selection
|
|
70
|
-
* focus sink): answered SYNCHRONOUSLY from the cache;
|
|
312
|
+
* has a DOM selection): answered synchronously from the cache;
|
|
71
313
|
* - ctrl/cmd+C with no DOM selection (canvas-rendered viewers usually
|
|
72
314
|
* have none, and browsers don't dispatch `copy` then): a keydown
|
|
73
315
|
* fallback writes via the async Clipboard API inside the keystroke's
|
|
74
316
|
* user-activation window.
|
|
75
317
|
*
|
|
318
|
+
* A selection the user may not copy is never copied by either path, nor by
|
|
319
|
+
* the browser: the key and the copy are prevented, and `onRefused` says so.
|
|
320
|
+
* Keys and copies in a text field are the field's, and left alone. With an
|
|
321
|
+
* element as `target`, only the keys and copies made while focus is inside it
|
|
322
|
+
* are its own.
|
|
323
|
+
*
|
|
76
324
|
* Returns the unwire function. Mount once per document view (not per page).
|
|
77
325
|
*/
|
|
78
326
|
function wireSelectionClipboard(selection, options = {}) {
|
|
79
327
|
const target = options.target ?? document;
|
|
328
|
+
const doc = target.ownerDocument ?? target;
|
|
329
|
+
const isOurs = () => target === doc || containsFocus(target);
|
|
80
330
|
const prefetch = options.prefetch ?? true;
|
|
81
331
|
let cached = null;
|
|
82
332
|
let generation = 0;
|
|
@@ -89,40 +339,58 @@ function wireSelectionClipboard(selection, options = {}) {
|
|
|
89
339
|
if (gen === generation) cached = text;
|
|
90
340
|
}, () => {});
|
|
91
341
|
};
|
|
92
|
-
const offCommit = selection.
|
|
342
|
+
const offCommit = selection.onCommitted(() => {
|
|
93
343
|
if (timer) clearTimeout(timer);
|
|
94
344
|
timer = null;
|
|
95
345
|
refresh();
|
|
96
346
|
});
|
|
97
|
-
const offChange = selection.
|
|
347
|
+
const offChange = selection.onChanged(() => {
|
|
98
348
|
generation++;
|
|
99
349
|
cached = null;
|
|
100
350
|
if (timer) clearTimeout(timer);
|
|
101
351
|
timer = setTimeout(refresh, PREFETCH_DEBOUNCE_MS);
|
|
102
352
|
});
|
|
103
|
-
|
|
353
|
+
/** Whether there is a selection the user may not copy: the copy is then refused. */
|
|
354
|
+
const isRefused = () => selection.hasSelection() && !selection.canCopy();
|
|
355
|
+
const refuse = (event) => {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
options.onRefused?.();
|
|
358
|
+
};
|
|
359
|
+
const onCopy = (event) => {
|
|
360
|
+
if (!isOurs() || isFromTextField(event)) return;
|
|
361
|
+
if (isRefused()) return refuse(event);
|
|
104
362
|
if (cached == null || cached === "" || !selection.hasSelection()) return;
|
|
105
|
-
|
|
106
|
-
|
|
363
|
+
event.clipboardData?.setData("text/plain", cached);
|
|
364
|
+
event.preventDefault();
|
|
107
365
|
};
|
|
108
|
-
const onKeyDown = (
|
|
109
|
-
if (!(
|
|
366
|
+
const onKeyDown = (event) => {
|
|
367
|
+
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "c" || event.shiftKey || event.altKey || event.defaultPrevented || !isOurs() || isFromTextField(event)) return;
|
|
368
|
+
if (isRefused()) return refuse(event);
|
|
110
369
|
if (!selection.hasSelection() || !selection.canCopy()) return;
|
|
111
|
-
const dom = target
|
|
370
|
+
const dom = domSelectionOf(target);
|
|
112
371
|
if (dom && !dom.isCollapsed) return;
|
|
113
372
|
copySelection(selection);
|
|
114
373
|
};
|
|
115
|
-
|
|
116
|
-
|
|
374
|
+
doc.addEventListener("copy", onCopy);
|
|
375
|
+
doc.addEventListener("keydown", onKeyDown);
|
|
117
376
|
return () => {
|
|
118
377
|
offCommit();
|
|
119
378
|
offChange();
|
|
120
379
|
if (timer) clearTimeout(timer);
|
|
121
|
-
|
|
122
|
-
|
|
380
|
+
doc.removeEventListener("copy", onCopy);
|
|
381
|
+
doc.removeEventListener("keydown", onKeyDown);
|
|
123
382
|
};
|
|
124
383
|
}
|
|
125
384
|
/**
|
|
385
|
+
* The DOM selection the browser copies from: the target's root's (its shadow root, where the
|
|
386
|
+
* browser keeps one there, or its document), else its owner document's.
|
|
387
|
+
*/
|
|
388
|
+
function domSelectionOf(target) {
|
|
389
|
+
const root = target.getRootNode();
|
|
390
|
+
if (typeof root.getSelection === "function") return root.getSelection();
|
|
391
|
+
return target.ownerDocument?.getSelection() ?? null;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
126
394
|
* Toolbar/menu path: read the selected text and write it to the clipboard
|
|
127
395
|
* via the async Clipboard API. Call from a user-gesture handler (browsers
|
|
128
396
|
* require a secure context + user activation for clipboard writes). Resolves
|
|
@@ -135,21 +403,77 @@ async function copySelection(selection) {
|
|
|
135
403
|
return text;
|
|
136
404
|
}
|
|
137
405
|
//#endregion
|
|
406
|
+
//#region src/command-shortcuts.ts
|
|
407
|
+
/**
|
|
408
|
+
* Keyboard shortcuts for commands: one `keydown` listener runs the command a
|
|
409
|
+
* key matches. Matching is the commands plugin's; what this module adds is the
|
|
410
|
+
* browser's part: the platform, whether a key was typed into a text field, and
|
|
411
|
+
* the default action a shortcut replaces. Structurally typed, so it needs no EmbedPDF
|
|
412
|
+
* package (the commands host capability satisfies {@link ShortcutCommands}).
|
|
413
|
+
*/
|
|
414
|
+
/** Whether this is an Apple platform, where `Mod` is Cmd and shortcuts show as `⌘K`. */
|
|
415
|
+
const isMacPlatform = () => typeof navigator !== "undefined" && /Mac|iP(hone|ad|od)/.test(navigator.platform);
|
|
416
|
+
/**
|
|
417
|
+
* Run a command when its shortcut is pressed, and return the function that stops listening.
|
|
418
|
+
* Keys typed into a text field are left alone, except a command's that says it runs from text
|
|
419
|
+
* fields (`shortcutInTextFields`, as Find does). A command that's turned off or not allowed
|
|
420
|
+
* still takes its key, so the browser doesn't do something else with it (Cmd+P printing the
|
|
421
|
+
* page), and `onRefused` says why. A key whose command only can't run right now keeps its
|
|
422
|
+
* browser default.
|
|
423
|
+
*/
|
|
424
|
+
function bindCommandShortcuts(commands, options = {}) {
|
|
425
|
+
const isMac = options.isMac ?? isMacPlatform();
|
|
426
|
+
const target = options.target ?? window;
|
|
427
|
+
const onKeyDown = (event) => {
|
|
428
|
+
const stroke = event;
|
|
429
|
+
if (stroke.defaultPrevented) return;
|
|
430
|
+
const match = commands.matchStroke(stroke, {
|
|
431
|
+
isMac,
|
|
432
|
+
inTextField: isFromTextField(stroke)
|
|
433
|
+
});
|
|
434
|
+
if (!match) return;
|
|
435
|
+
if (match.refusal) {
|
|
436
|
+
stroke.preventDefault();
|
|
437
|
+
options.onRefused?.(match.commandId, match.refusal);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (!commands.canExecute(match.commandId)) return;
|
|
441
|
+
stroke.preventDefault();
|
|
442
|
+
commands.execute(match.commandId).catch(() => {});
|
|
443
|
+
};
|
|
444
|
+
target.addEventListener("keydown", onKeyDown);
|
|
445
|
+
return () => target.removeEventListener("keydown", onKeyDown);
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
138
448
|
//#region src/anchored-position.ts
|
|
139
449
|
/**
|
|
140
|
-
*
|
|
450
|
+
* The anchored projection: anchor → screen position, in one pure call —
|
|
141
451
|
* projection, avoid-point transform, and the shared placement policy.
|
|
142
452
|
* Frameworks recompute this inside their native reactive primitive
|
|
143
453
|
* (a React render, a Vue `computed`, an Angular signal, a Svelte
|
|
144
454
|
* `$derived`); nothing here schedules anything.
|
|
455
|
+
*
|
|
456
|
+
* Null when there is nothing to show: the page isn't shown, or the box is so
|
|
457
|
+
* far out of view that the UI around it can't reach into it.
|
|
145
458
|
*/
|
|
146
|
-
function projectAnchoredTarget(projector, anchor, placement, gap) {
|
|
147
|
-
const
|
|
459
|
+
function projectAnchoredTarget(projector, anchor, { placement, gap, pinned = false }, fit) {
|
|
460
|
+
const env = anchor.boundsIn ? projector.viewEnv(anchor.page) : null;
|
|
461
|
+
const bounds = env && anchor.boundsIn ? anchor.boundsIn(env) : anchor.bounds;
|
|
462
|
+
if (!bounds) return null;
|
|
463
|
+
const box = projector.toScreen(anchor.page, bounds);
|
|
148
464
|
if (!box) return null;
|
|
149
|
-
|
|
465
|
+
const view = projector.view();
|
|
466
|
+
const reach = Math.abs(gap) + (fit ? Math.max(fit.size.width, fit.size.height) : 0);
|
|
467
|
+
if (view && !overlaps(box, view, reach)) return null;
|
|
468
|
+
const avoid = anchor.avoid?.length ? projector.toScreenPoint(anchor.page, anchor.avoid[0]) : null;
|
|
469
|
+
return fit && !pinned ? fitAnchoredRect(box, placement, gap, fit, avoid) : positionAnchoredRect(box, placement, gap, avoid);
|
|
470
|
+
}
|
|
471
|
+
/** Whether `box`, grown by `margin` on every side, touches `view`. */
|
|
472
|
+
function overlaps(box, view, margin) {
|
|
473
|
+
return box.x - margin < view.x + view.width && box.x + box.width + margin > view.x && box.y - margin < view.y + view.height && box.y + box.height + margin > view.y;
|
|
150
474
|
}
|
|
151
475
|
/**
|
|
152
|
-
* The one genuinely
|
|
476
|
+
* The one genuinely browser-driven invalidation: an element in normal
|
|
153
477
|
* document flow moves when the document scrolls or the window resizes —
|
|
154
478
|
* no state change announces it. `<PageView>`'s binding registers this;
|
|
155
479
|
* the Stage never does (its camera is state, delivered through render).
|
|
@@ -162,31 +486,256 @@ function observeClientGeometry(callback) {
|
|
|
162
486
|
window.removeEventListener("resize", callback);
|
|
163
487
|
};
|
|
164
488
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
489
|
+
/**
|
|
490
|
+
* The area anchored UI stays inside: the surface's own box (`'overlay'` space, the element's
|
|
491
|
+
* positioned parent) or the window (`'client'` space). Null while the element has no
|
|
492
|
+
* positioned parent to measure.
|
|
493
|
+
*/
|
|
494
|
+
function anchoredViewOf(element, space) {
|
|
495
|
+
if (space === "client") {
|
|
496
|
+
const { clientWidth, clientHeight } = element.ownerDocument.documentElement;
|
|
497
|
+
return {
|
|
498
|
+
x: 0,
|
|
499
|
+
y: 0,
|
|
500
|
+
width: clientWidth,
|
|
501
|
+
height: clientHeight
|
|
173
502
|
};
|
|
503
|
+
}
|
|
504
|
+
const container = element.offsetParent;
|
|
505
|
+
return container ? {
|
|
506
|
+
x: 0,
|
|
507
|
+
y: 0,
|
|
508
|
+
width: container.clientWidth,
|
|
509
|
+
height: container.clientHeight
|
|
510
|
+
} : null;
|
|
511
|
+
}
|
|
512
|
+
/** Whether two measurements give the same sizes, so a placement made from the first still holds. */
|
|
513
|
+
function sameAnchoredFit(left, right) {
|
|
514
|
+
return left !== null && left.size.width === right.size.width && left.size.height === right.size.height && left.view.width === right.view.width && left.view.height === right.view.height;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Measure anchored UI's `element` and the area it stays inside ({@link anchoredViewOf}) now, and
|
|
518
|
+
* again whenever either resizes: `report` hears every measurement (keep it when
|
|
519
|
+
* {@link sameAnchoredFit} says it changed). A camera move changes neither, so it measures nothing.
|
|
520
|
+
* Returns the function that stops measuring.
|
|
521
|
+
*/
|
|
522
|
+
function observeAnchoredFit(element, space, report) {
|
|
523
|
+
const measure = () => {
|
|
524
|
+
const view = anchoredViewOf(element, space);
|
|
525
|
+
if (!view) return;
|
|
526
|
+
report({
|
|
527
|
+
size: {
|
|
528
|
+
width: element.offsetWidth,
|
|
529
|
+
height: element.offsetHeight
|
|
530
|
+
},
|
|
531
|
+
view
|
|
532
|
+
});
|
|
533
|
+
};
|
|
534
|
+
measure();
|
|
535
|
+
const observer = new ResizeObserver(measure);
|
|
536
|
+
observer.observe(element);
|
|
537
|
+
if (space === "overlay" && element.offsetParent) observer.observe(element.offsetParent);
|
|
538
|
+
else window.addEventListener("resize", measure);
|
|
539
|
+
return () => {
|
|
540
|
+
observer.disconnect();
|
|
541
|
+
window.removeEventListener("resize", measure);
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/** A placement's side and how it lines up along that side. */
|
|
545
|
+
function splitPlacement(placement) {
|
|
546
|
+
const [side, align] = placement.split("-");
|
|
547
|
+
return {
|
|
548
|
+
side,
|
|
549
|
+
align: align ?? "center"
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function placeAround(box, placement, gap, avoid) {
|
|
553
|
+
const { side, align } = splitPlacement(placement);
|
|
554
|
+
const along = (start, length) => align === "start" ? start : align === "end" ? start + length : start + length / 2;
|
|
555
|
+
const alongShift = align === "start" ? 0 : align === "end" ? 1 : .5;
|
|
556
|
+
switch (side) {
|
|
557
|
+
case "bottom": {
|
|
558
|
+
const edge = Math.max(box.y + box.height, avoid ? avoid.y : -Infinity);
|
|
559
|
+
return {
|
|
560
|
+
x: along(box.x, box.width),
|
|
561
|
+
y: edge + gap,
|
|
562
|
+
shiftX: alongShift,
|
|
563
|
+
shiftY: 0
|
|
564
|
+
};
|
|
565
|
+
}
|
|
174
566
|
case "left": return {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
567
|
+
x: Math.min(box.x, avoid ? avoid.x : Infinity) - gap,
|
|
568
|
+
y: along(box.y, box.height),
|
|
569
|
+
shiftX: 1,
|
|
570
|
+
shiftY: alongShift
|
|
178
571
|
};
|
|
179
572
|
case "right": return {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
573
|
+
x: Math.max(box.x + box.width, avoid ? avoid.x : -Infinity) + gap,
|
|
574
|
+
y: along(box.y, box.height),
|
|
575
|
+
shiftX: 0,
|
|
576
|
+
shiftY: alongShift
|
|
577
|
+
};
|
|
578
|
+
default: {
|
|
579
|
+
const edge = Math.min(box.y, avoid ? avoid.y : Infinity);
|
|
580
|
+
return {
|
|
581
|
+
x: along(box.x, box.width),
|
|
582
|
+
y: edge - gap,
|
|
583
|
+
shiftX: alongShift,
|
|
584
|
+
shiftY: 1
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const shiftCss = (shift) => shift === 0 ? "0" : `${-shift * 100}%`;
|
|
590
|
+
/**
|
|
591
|
+
* Place an upright element around `box` (screen px). `avoid` is a screen
|
|
592
|
+
* point the element must clear (e.g. the rotate knob): the element extends
|
|
593
|
+
* only the edge it sits on, and only when the point protrudes past that
|
|
594
|
+
* edge — so it clears the obstacle without ever shifting along the side.
|
|
595
|
+
* When the point is on another side (e.g. a 90° shape, knob at mid-height
|
|
596
|
+
* for a `top` placement) the edge is untouched.
|
|
597
|
+
*/
|
|
598
|
+
function positionAnchoredRect(box, placement, gap, avoid) {
|
|
599
|
+
const placed = placeAround(box, placement, gap, avoid);
|
|
600
|
+
return {
|
|
601
|
+
left: placed.x,
|
|
602
|
+
top: placed.y,
|
|
603
|
+
transform: `translate(${shiftCss(placed.shiftX)}, ${shiftCss(placed.shiftY)})`,
|
|
604
|
+
placement
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
const OPPOSITE = {
|
|
608
|
+
top: "bottom",
|
|
609
|
+
bottom: "top",
|
|
610
|
+
left: "right",
|
|
611
|
+
right: "left"
|
|
612
|
+
};
|
|
613
|
+
/** The same placement on the other side of the box: `'top-end'` becomes `'bottom-end'`. */
|
|
614
|
+
const opposite = (placement) => {
|
|
615
|
+
const { side, align } = splitPlacement(placement);
|
|
616
|
+
const flipped = OPPOSITE[side];
|
|
617
|
+
return align === "center" ? flipped : `${flipped}-${align}`;
|
|
618
|
+
};
|
|
619
|
+
/**
|
|
620
|
+
* Place an element of a known size around `box`, inside `fit.view`. It sits
|
|
621
|
+
* where `placement` says when it fits there; otherwise on the opposite side,
|
|
622
|
+
* lined up the same way, when that fits or has more room. Then, while `box`
|
|
623
|
+
* is in view, it moves along both axes to stay inside the view, `gap` from
|
|
624
|
+
* its edges; UI whose box scrolled out of view goes with it. The result is
|
|
625
|
+
* the element's top-left corner, with no transform.
|
|
626
|
+
*/
|
|
627
|
+
function fitAnchoredRect(box, placement, gap, fit, avoid) {
|
|
628
|
+
const { size, view } = fit;
|
|
629
|
+
const inner = {
|
|
630
|
+
left: view.x + gap,
|
|
631
|
+
top: view.y + gap,
|
|
632
|
+
right: view.x + view.width - gap,
|
|
633
|
+
bottom: view.y + view.height - gap
|
|
634
|
+
};
|
|
635
|
+
const cornerOn = (at) => {
|
|
636
|
+
const placed = placeAround(box, at, gap, avoid);
|
|
637
|
+
return {
|
|
638
|
+
x: placed.x - placed.shiftX * size.width,
|
|
639
|
+
y: placed.y - placed.shiftY * size.height
|
|
183
640
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
641
|
+
};
|
|
642
|
+
const fits = (at, corner) => {
|
|
643
|
+
const { side } = splitPlacement(at);
|
|
644
|
+
if (side === "top") return corner.y >= inner.top;
|
|
645
|
+
if (side === "bottom") return corner.y + size.height <= inner.bottom;
|
|
646
|
+
if (side === "left") return corner.x >= inner.left;
|
|
647
|
+
return corner.x + size.width <= inner.right;
|
|
648
|
+
};
|
|
649
|
+
/** The space between the box and the view's edge on a placement's side. */
|
|
650
|
+
const roomOn = (at) => {
|
|
651
|
+
const { side } = splitPlacement(at);
|
|
652
|
+
if (side === "top") return box.y - view.y;
|
|
653
|
+
if (side === "bottom") return view.y + view.height - (box.y + box.height);
|
|
654
|
+
if (side === "left") return box.x - view.x;
|
|
655
|
+
return view.x + view.width - (box.x + box.width);
|
|
656
|
+
};
|
|
657
|
+
let chosen = placement;
|
|
658
|
+
let corner = cornerOn(chosen);
|
|
659
|
+
if (!fits(chosen, corner)) {
|
|
660
|
+
const other = opposite(chosen);
|
|
661
|
+
const otherCorner = cornerOn(other);
|
|
662
|
+
if (fits(other, otherCorner) || roomOn(other) > roomOn(chosen)) {
|
|
663
|
+
chosen = other;
|
|
664
|
+
corner = otherCorner;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (box.x < view.x + view.width && box.x + box.width > view.x && box.y < view.y + view.height && box.y + box.height > view.y) {
|
|
668
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
|
|
669
|
+
corner = {
|
|
670
|
+
x: clamp(corner.x, inner.left, inner.right - size.width),
|
|
671
|
+
y: clamp(corner.y, inner.top, inner.bottom - size.height)
|
|
188
672
|
};
|
|
189
673
|
}
|
|
674
|
+
return {
|
|
675
|
+
left: corner.x,
|
|
676
|
+
top: corner.y,
|
|
677
|
+
transform: "none",
|
|
678
|
+
placement: chosen
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/annotation-frame.ts
|
|
683
|
+
const turnCss = (rotation) => rotation ? `rotate(${rotation}deg)` : void 0;
|
|
684
|
+
/** A frame in the page layer's pixels: where it is, its turn, and the size and scale a look is drawn at. */
|
|
685
|
+
function frameInPixels(frame, page) {
|
|
686
|
+
const { box } = frame;
|
|
687
|
+
const topLeft = page.toPixels({
|
|
688
|
+
x: box.x,
|
|
689
|
+
y: box.y
|
|
690
|
+
});
|
|
691
|
+
const bottomRight = page.toPixels({
|
|
692
|
+
x: box.x + box.width,
|
|
693
|
+
y: box.y + box.height
|
|
694
|
+
});
|
|
695
|
+
const width = bottomRight.x - topLeft.x;
|
|
696
|
+
const height = bottomRight.y - topLeft.y;
|
|
697
|
+
const scale = frame.scale * page.zoom || 1;
|
|
698
|
+
return {
|
|
699
|
+
left: topLeft.x,
|
|
700
|
+
top: topLeft.y,
|
|
701
|
+
width,
|
|
702
|
+
height,
|
|
703
|
+
transform: turnCss(frame.rotation),
|
|
704
|
+
rotationOnScreen: ((frame.rotation + page.rotation) % 360 + 360) % 360,
|
|
705
|
+
scale,
|
|
706
|
+
design: {
|
|
707
|
+
width: width / scale,
|
|
708
|
+
height: height / scale
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* A raster inside its frame, as CSS percentages of the frame. `raster`'s box
|
|
714
|
+
* is relative to the frame's box, as the annotation plugin hands it out.
|
|
715
|
+
*/
|
|
716
|
+
function rasterInFrame(raster, frame) {
|
|
717
|
+
const percent = (part, whole) => `${whole > 0 ? part / whole * 100 : 0}%`;
|
|
718
|
+
return {
|
|
719
|
+
left: percent(raster.box.x, frame.box.width),
|
|
720
|
+
top: percent(raster.box.y, frame.box.height),
|
|
721
|
+
width: percent(raster.box.width, frame.box.width),
|
|
722
|
+
height: percent(raster.box.height, frame.box.height),
|
|
723
|
+
transform: turnCss(raster.rotation)
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* The frame a look draws into. A `scaled` look (the default) draws at the
|
|
728
|
+
* annotation's 100% size (`design`) inside an element of that size, which
|
|
729
|
+
* the layer scales by `scale` from its top-left corner; an unscaled look
|
|
730
|
+
* draws at the frame's size on screen.
|
|
731
|
+
*/
|
|
732
|
+
function lookFrameOf(frame, scaled) {
|
|
733
|
+
return {
|
|
734
|
+
width: scaled ? frame.design.width : frame.width,
|
|
735
|
+
height: scaled ? frame.design.height : frame.height,
|
|
736
|
+
rotation: frame.rotationOnScreen,
|
|
737
|
+
scale: frame.scale
|
|
738
|
+
};
|
|
190
739
|
}
|
|
191
740
|
//#endregion
|
|
192
741
|
//#region src/cursor.ts
|
|
@@ -198,17 +747,28 @@ function svgCursor({ svg, hotspot, fallback = "crosshair" }) {
|
|
|
198
747
|
if (!/\swidth\s*=/.test(openTag) || !/\sheight\s*=/.test(openTag)) throw new Error("[web] svgCursor: the <svg> needs explicit width/height attributes — browsers ignore cursor images without an intrinsic size");
|
|
199
748
|
return `url("${`data:image/svg+xml,${encodeURIComponent(svg)}`}")${hotspot ? ` ${Math.round(hotspot.x)} ${Math.round(hotspot.y)}` : ""}, ${fallback}`;
|
|
200
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* A tool's cursors as CSS cursor values, for the interaction hub's `setToolCursor(toolId,
|
|
752
|
+
* cursors)`: a string stays as it is, an SVG becomes its `url(…)` cursor, falling back to the
|
|
753
|
+
* keyword it replaces unless it names its own fallback.
|
|
754
|
+
*/
|
|
755
|
+
function toolCursorsOf(cursors) {
|
|
756
|
+
return Object.fromEntries(Object.entries(cursors).map(([keyword, image]) => [keyword, typeof image === "string" ? image : svgCursor(image.fallback === void 0 ? {
|
|
757
|
+
...image,
|
|
758
|
+
fallback: keyword
|
|
759
|
+
} : image)]));
|
|
760
|
+
}
|
|
201
761
|
//#endregion
|
|
202
762
|
//#region src/external-uri.ts
|
|
203
763
|
/**
|
|
204
|
-
* External-URI hygiene for PDF link annotations — written
|
|
764
|
+
* External-URI hygiene for PDF link annotations — written once here so every
|
|
205
765
|
* framework adapter (react, vue, svelte, angular) shares the same policy.
|
|
206
766
|
*
|
|
207
767
|
* PDFs are untrusted input: a link's `/URI` can carry anything, including
|
|
208
768
|
* `javascript:` payloads. The default allowlist covers what a document link
|
|
209
769
|
* legitimately opens; everything else (javascript:, file:, data:, vbscript:,
|
|
210
770
|
* custom app schemes) resolves to `null` and the adapters render the link
|
|
211
|
-
*
|
|
771
|
+
* without an href — activation still reports through the plugin, so an
|
|
212
772
|
* embedder that needs a custom scheme can handle it in `onActivate`.
|
|
213
773
|
*/
|
|
214
774
|
const DEFAULT_ALLOWED_SCHEMES = /* @__PURE__ */ new Set([
|
|
@@ -217,38 +777,64 @@ const DEFAULT_ALLOWED_SCHEMES = /* @__PURE__ */ new Set([
|
|
|
217
777
|
"mailto:",
|
|
218
778
|
"tel:"
|
|
219
779
|
]);
|
|
780
|
+
/** A URI that names its scheme (RFC 3986: a letter, then letters, digits, `+`, `-` or `.`). */
|
|
781
|
+
const SCHEME = /^[a-z][a-z\d+.-]*:/i;
|
|
782
|
+
/**
|
|
783
|
+
* The absolute address a link means, never read against the page around the viewer: one with
|
|
784
|
+
* a scheme as written; a scheme-relative `//host` on https; a bare `www.` host on https, as an
|
|
785
|
+
* address bar reads it. Anything else is relative to the document (`/help`, `../x`, `page2.html`)
|
|
786
|
+
* and has no document address to resolve against here, so it means nothing: read against the
|
|
787
|
+
* host page, it would open the app's own pages.
|
|
788
|
+
*/
|
|
789
|
+
function absoluteUri(uri) {
|
|
790
|
+
if (SCHEME.test(uri)) return uri;
|
|
791
|
+
if (uri.startsWith("//")) return `https:${uri}`;
|
|
792
|
+
if (/^www\./i.test(uri)) return `https://${uri}`;
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
220
795
|
/**
|
|
221
|
-
* Validate a link URI for opening in the browser. Returns the URI when its
|
|
222
|
-
* scheme is allowed (scheme-relative and bare URIs
|
|
223
|
-
*
|
|
796
|
+
* Validate a link URI for opening in the browser. Returns the absolute URI to open when its
|
|
797
|
+
* scheme is allowed (scheme-relative and bare `www.` URIs become https), else `null`. Never
|
|
798
|
+
* throws — a malformed URI is just an unopenable one.
|
|
224
799
|
*/
|
|
225
800
|
function sanitizeExternalUri(uri, allowedSchemes = DEFAULT_ALLOWED_SCHEMES) {
|
|
226
|
-
const
|
|
227
|
-
if (!
|
|
228
|
-
const allowed = new Set([...allowedSchemes].map((
|
|
801
|
+
const absolute = absoluteUri(uri.trim());
|
|
802
|
+
if (!absolute) return null;
|
|
803
|
+
const allowed = new Set([...allowedSchemes].map((scheme) => scheme.endsWith(":") ? scheme.toLowerCase() : `${scheme.toLowerCase()}:`));
|
|
229
804
|
try {
|
|
230
|
-
|
|
231
|
-
return allowed.has(url.protocol) ? trimmed : null;
|
|
805
|
+
return allowed.has(new URL(absolute).protocol) ? absolute : null;
|
|
232
806
|
} catch {
|
|
233
807
|
return null;
|
|
234
808
|
}
|
|
235
809
|
}
|
|
810
|
+
/**
|
|
811
|
+
* Open a link target in a new tab when its address is allowed (`http`,
|
|
812
|
+
* `https`, `mailto`, `tel`); anything else is refused. True when it opened.
|
|
813
|
+
* Call it inside the user's click (the link plugin's `activate()` does), so
|
|
814
|
+
* the click is still the gesture that opens the tab.
|
|
815
|
+
*/
|
|
816
|
+
function openExternalUri(uri) {
|
|
817
|
+
const href = sanitizeExternalUri(uri);
|
|
818
|
+
if (!href || typeof window === "undefined") return false;
|
|
819
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
236
822
|
//#endregion
|
|
237
823
|
//#region src/actions-ui.ts
|
|
238
824
|
/**
|
|
239
|
-
* The
|
|
240
|
-
* the browser fallbacks, written
|
|
241
|
-
* (react, angular, …) ships the
|
|
825
|
+
* The default actions UI adapter — the origin×phase visibility matrix plus
|
|
826
|
+
* the browser fallbacks, written once here so every framework binding
|
|
827
|
+
* (react, angular, …) ships the same policy instead of forking it per
|
|
242
828
|
* framework. Types are structural twins of `@embedpdf/plugin-actions`'
|
|
243
829
|
* adapter contract (declared here so this package stays plugin-free, per
|
|
244
830
|
* the layering law); assignability is checked where a binding installs it.
|
|
245
831
|
*
|
|
246
|
-
* The matrix (defaults only — an embedder override receives
|
|
832
|
+
* The matrix (defaults only — an embedder override receives every effect
|
|
247
833
|
* with its context attached and decides for itself):
|
|
248
834
|
* - URIs open through `sanitizeExternalUri` in a new tab (the dispatcher
|
|
249
835
|
* already policy-gated the node; blocked schemes are dropped here).
|
|
250
836
|
* - Print: hover/lifecycle-origin requests never open the dialog; user
|
|
251
|
-
* requests fall back to `globalThis.print`. The `doc.print`
|
|
837
|
+
* requests fall back to `globalThis.print`. The `doc.print` authority
|
|
252
838
|
* gate is upstream in the plugin and not overridable.
|
|
253
839
|
* - Alert: document-open nags (lifecycle origin, boot phase) never reach
|
|
254
840
|
* `window.alert` — the boot-nag gap, closed.
|
|
@@ -257,28 +843,28 @@ function sanitizeExternalUri(uri, allowedSchemes = DEFAULT_ALLOWED_SCHEMES) {
|
|
|
257
843
|
const createDefaultActionsUiAdapter = (options = {}) => {
|
|
258
844
|
const overridesOf = () => typeof options.overrides === "function" ? options.overrides() : options.overrides;
|
|
259
845
|
return {
|
|
260
|
-
openUri: (uri,
|
|
846
|
+
openUri: (uri, options) => {
|
|
261
847
|
const current = overridesOf();
|
|
262
848
|
if (current?.openUri) {
|
|
263
|
-
current.openUri(uri,
|
|
849
|
+
current.openUri(uri, options);
|
|
264
850
|
return;
|
|
265
851
|
}
|
|
266
852
|
const href = sanitizeExternalUri(uri);
|
|
267
853
|
if (href && typeof window !== "undefined") window.open(href, "_blank", "noopener,noreferrer");
|
|
268
854
|
},
|
|
269
|
-
print: (
|
|
855
|
+
print: (options) => {
|
|
270
856
|
const current = overridesOf();
|
|
271
|
-
if (current?.print) current.print(
|
|
272
|
-
else if (
|
|
857
|
+
if (current?.print) current.print(options);
|
|
858
|
+
else if (options && options.origin !== "user") {} else if (typeof globalThis.print === "function") globalThis.print();
|
|
273
859
|
},
|
|
274
|
-
alert: (message,
|
|
860
|
+
alert: (message, options) => {
|
|
275
861
|
const current = overridesOf();
|
|
276
|
-
if (current?.alert) current.alert(message,
|
|
277
|
-
else if (
|
|
862
|
+
if (current?.alert) current.alert(message, options);
|
|
863
|
+
else if (options.origin === "lifecycle" || options.phase === "boot") {} else if (typeof globalThis.alert === "function") globalThis.alert(message);
|
|
278
864
|
},
|
|
279
|
-
gotoPage: (page,
|
|
865
|
+
gotoPage: (page, effectContext) => {
|
|
280
866
|
const current = overridesOf();
|
|
281
|
-
if (current?.gotoPage) current.gotoPage(page,
|
|
867
|
+
if (current?.gotoPage) current.gotoPage(page, effectContext);
|
|
282
868
|
else options.goToPage?.(page);
|
|
283
869
|
}
|
|
284
870
|
};
|
|
@@ -311,7 +897,7 @@ function bindPaintedImage(image, source, callbacks) {
|
|
|
311
897
|
});
|
|
312
898
|
};
|
|
313
899
|
image.addEventListener("load", onLoad, { once: true });
|
|
314
|
-
source.objectUrl(controller.signal).then((objectUrl) => {
|
|
900
|
+
source.objectUrl().abortWith(controller.signal).then((objectUrl) => {
|
|
315
901
|
if (controller.signal.aborted) {
|
|
316
902
|
objectUrl.revoke();
|
|
317
903
|
return;
|
|
@@ -361,7 +947,7 @@ const vibrationFeedback = {
|
|
|
361
947
|
};
|
|
362
948
|
/**
|
|
363
949
|
* A provider for native iOS shells: forwards each call to a WKWebView script
|
|
364
|
-
* message handler the
|
|
950
|
+
* message handler the host app installed under `handlerName` — an explicit
|
|
365
951
|
* contract between the embedder and their own app, never a global this library
|
|
366
952
|
* sniffs on its own. The message shape is
|
|
367
953
|
* `{ family: 'selection' } | { family: 'impact', weight } | { family: 'notify', kind }`;
|
|
@@ -376,21 +962,21 @@ const wkFeedback = (handlerName, fallback = vibrationFeedback) => {
|
|
|
376
962
|
};
|
|
377
963
|
return {
|
|
378
964
|
selection: () => {
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
965
|
+
const messageHandler = handler();
|
|
966
|
+
if (messageHandler) messageHandler.postMessage({ family: "selection" });
|
|
381
967
|
else fallback.selection();
|
|
382
968
|
},
|
|
383
969
|
impact: (weight = "light") => {
|
|
384
|
-
const
|
|
385
|
-
if (
|
|
970
|
+
const messageHandler = handler();
|
|
971
|
+
if (messageHandler) messageHandler.postMessage({
|
|
386
972
|
family: "impact",
|
|
387
973
|
weight
|
|
388
974
|
});
|
|
389
975
|
else fallback.impact(weight);
|
|
390
976
|
},
|
|
391
977
|
notify: (kind) => {
|
|
392
|
-
const
|
|
393
|
-
if (
|
|
978
|
+
const messageHandler = handler();
|
|
979
|
+
if (messageHandler) messageHandler.postMessage({
|
|
394
980
|
family: "notify",
|
|
395
981
|
kind
|
|
396
982
|
});
|
|
@@ -403,46 +989,46 @@ const wkFeedback = (handlerName, fallback = vibrationFeedback) => {
|
|
|
403
989
|
/** The per-notch step for discrete wheels — the zoom buttons' ratio. */
|
|
404
990
|
const NOTCH = 1.2;
|
|
405
991
|
/** Zoom factor for a ctrl/cmd wheel event (the caller decides zoom vs pan). */
|
|
406
|
-
function wheelZoomFactor(
|
|
407
|
-
if (
|
|
408
|
-
if (
|
|
409
|
-
if (Math.abs(
|
|
410
|
-
return Math.exp(-
|
|
992
|
+
function wheelZoomFactor(sample) {
|
|
993
|
+
if (sample.deltaMode !== 0) return Math.pow(NOTCH, -Math.sign(sample.deltaY));
|
|
994
|
+
if (sample.ctrlKey) {
|
|
995
|
+
if (Math.abs(sample.deltaY) >= 100) return Math.pow(NOTCH, -Math.sign(sample.deltaY));
|
|
996
|
+
return Math.exp(-sample.deltaY / 100);
|
|
411
997
|
}
|
|
412
|
-
return Math.exp(-
|
|
998
|
+
return Math.exp(-sample.deltaY * .0015);
|
|
413
999
|
}
|
|
414
1000
|
//#endregion
|
|
415
1001
|
//#region src/stage-gestures.ts
|
|
416
1002
|
/**
|
|
417
|
-
* Stage gesture controller — the
|
|
1003
|
+
* Stage gesture controller — the one DOM input binding for a Stage surface,
|
|
418
1004
|
* shared by every framework adapter (React, Angular, …) so the feel can never
|
|
419
1005
|
* drift between them.
|
|
420
1006
|
*
|
|
421
1007
|
* The premise (see the stage plugin's camera doctrine): desktop inputs arrive
|
|
422
1008
|
* with physics already applied by the OS — momentum lives in the wheel stream,
|
|
423
|
-
* a trackpad pinch is one pre-arbitrated gesture stream. Touch arrives
|
|
1009
|
+
* a trackpad pinch is one pre-arbitrated gesture stream. Touch arrives raw:
|
|
424
1010
|
* with `touch-action: none` the platform scroller is out of the loop, so the
|
|
425
1011
|
* ballistics (velocity → fling) and the arbitration (is this contact a scroll,
|
|
426
1012
|
* a pinch, a tap, or a tool gesture?) must be synthesized here.
|
|
427
1013
|
*
|
|
428
|
-
* Arbitration is
|
|
1014
|
+
* Arbitration is modality-aware:
|
|
429
1015
|
* - touch — navigation-first: one finger pans (whatever tool is armed), two
|
|
430
1016
|
* fingers pinch-zoom around their centroid, release velocity flings, a tap
|
|
431
1017
|
* forwards as a click, a double-tap zoom-toggles, a long-press hands the
|
|
432
1018
|
* gesture to the interaction hub (text selection), and a second finger
|
|
433
|
-
* landing mid-tool-gesture
|
|
1019
|
+
* landing mid-tool-gesture cancels it into a pinch (the Notes/Procreate
|
|
434
1020
|
* convention).
|
|
435
1021
|
* - mouse/pen — tool-first, exactly the pre-existing behavior: with a hub,
|
|
436
1022
|
* every down/move/up forwards (pan is the pan tool's job); without one,
|
|
437
1023
|
* dragging pans. Wheel and Safari-trackpad gesture events are unchanged.
|
|
438
1024
|
*
|
|
439
|
-
* Camera writes are rAF-
|
|
1025
|
+
* Camera writes are rAF-coalesced: pointer events only update gesture state;
|
|
440
1026
|
* one animation-frame tick applies at most one pan and one zoom per frame,
|
|
441
1027
|
* inside the host's begin/endGesture transaction. Events may arrive at 120 Hz;
|
|
442
1028
|
* the camera moves at display rate.
|
|
443
1029
|
*
|
|
444
|
-
* Dependency note: this module speaks to the stage through the
|
|
445
|
-
* {@link StageGestureHost} interface (satisfied by `
|
|
1030
|
+
* Dependency note: this module speaks to the stage through the structural
|
|
1031
|
+
* {@link StageGestureHost} interface (satisfied by `StageHostCapability`) and to
|
|
446
1032
|
* the interaction hub through {@link StageGestureSink} (a closure the adapter
|
|
447
1033
|
* builds) — @embedpdf/web stays free of plugin imports, per the layering law.
|
|
448
1034
|
*/
|
|
@@ -450,7 +1036,7 @@ function wheelZoomFactor(e) {
|
|
|
450
1036
|
* Release velocity from a trail of pointer samples: the mean velocity over the
|
|
451
1037
|
* trailing `windowMs` (first-to-last inside the window). Null when the trail is
|
|
452
1038
|
* too thin or too stale to trust — the standard "held still, then let go"
|
|
453
|
-
* case, which must
|
|
1039
|
+
* case, which must not fling. Pure; exported for tests.
|
|
454
1040
|
*/
|
|
455
1041
|
function computeReleaseVelocity(samples, now, windowMs = 100) {
|
|
456
1042
|
let firstIdx = -1;
|
|
@@ -469,7 +1055,7 @@ function computeReleaseVelocity(samples, now, windowMs = 100) {
|
|
|
469
1055
|
};
|
|
470
1056
|
}
|
|
471
1057
|
/** Attach the gesture controller to a Stage container. Returns the detach fn. */
|
|
472
|
-
function createStageGestureController(
|
|
1058
|
+
function createStageGestureController(element, host, options) {
|
|
473
1059
|
const zoomGestures = options.zoomGestures ?? true;
|
|
474
1060
|
const wheelZoom = options.wheelZoomFactor ?? wheelZoomFactor;
|
|
475
1061
|
const sink = options.sink ?? null;
|
|
@@ -513,25 +1099,25 @@ function createStageGestureController(el, host, options) {
|
|
|
513
1099
|
let mX = 0;
|
|
514
1100
|
let mY = 0;
|
|
515
1101
|
let mCount = 0;
|
|
516
|
-
const clickCount = (
|
|
1102
|
+
const clickCount = (event) => {
|
|
517
1103
|
const now = Date.now();
|
|
518
|
-
mCount = now - mLast <= 400 && Math.hypot(
|
|
1104
|
+
mCount = now - mLast <= 400 && Math.hypot(event.clientX - mX, event.clientY - mY) <= 6 ? mCount + 1 : 1;
|
|
519
1105
|
mLast = now;
|
|
520
|
-
mX =
|
|
521
|
-
mY =
|
|
1106
|
+
mX = event.clientX;
|
|
1107
|
+
mY = event.clientY;
|
|
522
1108
|
return mCount;
|
|
523
1109
|
};
|
|
524
1110
|
const vpt = (clientX, clientY) => {
|
|
525
|
-
const
|
|
1111
|
+
const rect = element.getBoundingClientRect();
|
|
526
1112
|
return {
|
|
527
|
-
x: clientX -
|
|
528
|
-
y: clientY -
|
|
1113
|
+
x: clientX - rect.left,
|
|
1114
|
+
y: clientY - rect.top
|
|
529
1115
|
};
|
|
530
1116
|
};
|
|
531
1117
|
const touches = () => {
|
|
532
1118
|
const out = [];
|
|
533
|
-
pointers.forEach((
|
|
534
|
-
if (
|
|
1119
|
+
pointers.forEach((pointer) => {
|
|
1120
|
+
if (pointer.kind === "touch") out.push(pointer);
|
|
535
1121
|
});
|
|
536
1122
|
return out;
|
|
537
1123
|
};
|
|
@@ -581,8 +1167,8 @@ function createStageGestureController(el, host, options) {
|
|
|
581
1167
|
if (trail.length > 16) trail.shift();
|
|
582
1168
|
};
|
|
583
1169
|
const maybeFling = () => {
|
|
584
|
-
const
|
|
585
|
-
if (
|
|
1170
|
+
const velocity = computeReleaseVelocity(trail, performance.now());
|
|
1171
|
+
if (velocity && Math.hypot(velocity.vx, velocity.vy) >= FLING_MIN) host.fling(velocity.vx, velocity.vy);
|
|
586
1172
|
};
|
|
587
1173
|
const flushPanTo = (x, y) => {
|
|
588
1174
|
const dx = x - lastApplied.x;
|
|
@@ -619,12 +1205,12 @@ function createStageGestureController(el, host, options) {
|
|
|
619
1205
|
const tick = () => {
|
|
620
1206
|
frame = 0;
|
|
621
1207
|
if (mode === "pan" || mode === "mousedrag") {
|
|
622
|
-
const
|
|
623
|
-
if (
|
|
1208
|
+
const pointer = pointers.get(panId);
|
|
1209
|
+
if (pointer && dirty) flushPanTo(pointer.x, pointer.y);
|
|
624
1210
|
frame = requestAnimationFrame(tick);
|
|
625
1211
|
} else if (mode === "pinch") {
|
|
626
|
-
const [
|
|
627
|
-
if (
|
|
1212
|
+
const [first, second] = touches();
|
|
1213
|
+
if (first && second && dirty) flushPinch(first.x, first.y, second.x, second.y);
|
|
628
1214
|
frame = requestAnimationFrame(tick);
|
|
629
1215
|
}
|
|
630
1216
|
};
|
|
@@ -632,14 +1218,14 @@ function createStageGestureController(el, host, options) {
|
|
|
632
1218
|
if (!frame) frame = requestAnimationFrame(tick);
|
|
633
1219
|
};
|
|
634
1220
|
const enterPinch = () => {
|
|
635
|
-
const [
|
|
636
|
-
if (!
|
|
1221
|
+
const [first, second] = touches();
|
|
1222
|
+
if (!first || !second) return;
|
|
637
1223
|
mode = "pinch";
|
|
638
1224
|
lastApplied = {
|
|
639
|
-
x: (
|
|
640
|
-
y: (
|
|
1225
|
+
x: (first.x + second.x) / 2,
|
|
1226
|
+
y: (first.y + second.y) / 2
|
|
641
1227
|
};
|
|
642
|
-
lastSpan = Math.hypot(
|
|
1228
|
+
lastSpan = Math.hypot(first.x - second.x, first.y - second.y);
|
|
643
1229
|
trail = [];
|
|
644
1230
|
dirty = false;
|
|
645
1231
|
ensureFrames();
|
|
@@ -652,30 +1238,30 @@ function createStageGestureController(el, host, options) {
|
|
|
652
1238
|
touchToolGesture = true;
|
|
653
1239
|
sink.longPress(downEvent);
|
|
654
1240
|
};
|
|
655
|
-
const onDown = (
|
|
656
|
-
const kind =
|
|
1241
|
+
const onDown = (event) => {
|
|
1242
|
+
const kind = event.pointerType || "mouse";
|
|
657
1243
|
if (kind === "touch") lastTouchAt = performance.now();
|
|
658
1244
|
if (kind !== "touch") {
|
|
659
|
-
if (kind === "mouse" &&
|
|
1245
|
+
if (kind === "mouse" && event.button !== 0) return;
|
|
660
1246
|
if (mode !== "idle") return;
|
|
661
|
-
pointers.set(
|
|
662
|
-
id:
|
|
1247
|
+
pointers.set(event.pointerId, {
|
|
1248
|
+
id: event.pointerId,
|
|
663
1249
|
kind,
|
|
664
|
-
x:
|
|
665
|
-
y:
|
|
666
|
-
downX:
|
|
667
|
-
downY:
|
|
1250
|
+
x: event.clientX,
|
|
1251
|
+
y: event.clientY,
|
|
1252
|
+
downX: event.clientX,
|
|
1253
|
+
downY: event.clientY
|
|
668
1254
|
});
|
|
669
1255
|
if (sink) {
|
|
670
1256
|
mode = "tool";
|
|
671
|
-
sink.down(
|
|
1257
|
+
sink.down(event, clickCount(event));
|
|
672
1258
|
} else {
|
|
673
1259
|
mode = "mousedrag";
|
|
674
1260
|
begin();
|
|
675
|
-
panId =
|
|
1261
|
+
panId = event.pointerId;
|
|
676
1262
|
lastApplied = {
|
|
677
|
-
x:
|
|
678
|
-
y:
|
|
1263
|
+
x: event.clientX,
|
|
1264
|
+
y: event.clientY
|
|
679
1265
|
};
|
|
680
1266
|
ensureFrames();
|
|
681
1267
|
}
|
|
@@ -683,55 +1269,55 @@ function createStageGestureController(el, host, options) {
|
|
|
683
1269
|
}
|
|
684
1270
|
switch (mode) {
|
|
685
1271
|
case "idle": {
|
|
686
|
-
pointers.set(
|
|
687
|
-
id:
|
|
1272
|
+
pointers.set(event.pointerId, {
|
|
1273
|
+
id: event.pointerId,
|
|
688
1274
|
kind,
|
|
689
|
-
x:
|
|
690
|
-
y:
|
|
691
|
-
downX:
|
|
692
|
-
downY:
|
|
1275
|
+
x: event.clientX,
|
|
1276
|
+
y: event.clientY,
|
|
1277
|
+
downX: event.clientX,
|
|
1278
|
+
downY: event.clientY
|
|
693
1279
|
});
|
|
694
|
-
const moving = host.
|
|
695
|
-
if (!moving && sink?.claimsPoint?.(
|
|
1280
|
+
const moving = host.isMoving();
|
|
1281
|
+
if (!moving && sink?.claimsPoint?.(event)) {
|
|
696
1282
|
mode = "tool";
|
|
697
1283
|
touchToolGesture = true;
|
|
698
|
-
sink.down(
|
|
1284
|
+
sink.down(event, 1);
|
|
699
1285
|
break;
|
|
700
1286
|
}
|
|
701
1287
|
mode = "pending";
|
|
702
|
-
downEvent =
|
|
1288
|
+
downEvent = event;
|
|
703
1289
|
suppressTap = moving;
|
|
704
1290
|
begin(true);
|
|
705
1291
|
trail = [];
|
|
706
|
-
pushSample(
|
|
1292
|
+
pushSample(event.clientX, event.clientY);
|
|
707
1293
|
clearLongPress();
|
|
708
1294
|
if (sink && !suppressTap) longPressTimer = setTimeout(onLongPress, LONG_PRESS_MS);
|
|
709
1295
|
break;
|
|
710
1296
|
}
|
|
711
1297
|
case "pending":
|
|
712
1298
|
case "pan":
|
|
713
|
-
pointers.set(
|
|
714
|
-
id:
|
|
1299
|
+
pointers.set(event.pointerId, {
|
|
1300
|
+
id: event.pointerId,
|
|
715
1301
|
kind,
|
|
716
|
-
x:
|
|
717
|
-
y:
|
|
718
|
-
downX:
|
|
719
|
-
downY:
|
|
1302
|
+
x: event.clientX,
|
|
1303
|
+
y: event.clientY,
|
|
1304
|
+
downX: event.clientX,
|
|
1305
|
+
downY: event.clientY
|
|
720
1306
|
});
|
|
721
1307
|
clearLongPress();
|
|
722
1308
|
enterPinch();
|
|
723
1309
|
break;
|
|
724
1310
|
case "tool":
|
|
725
1311
|
if (!touchToolGesture) return;
|
|
726
|
-
sink?.cancel(
|
|
1312
|
+
sink?.cancel(event);
|
|
727
1313
|
touchToolGesture = false;
|
|
728
|
-
pointers.set(
|
|
729
|
-
id:
|
|
1314
|
+
pointers.set(event.pointerId, {
|
|
1315
|
+
id: event.pointerId,
|
|
730
1316
|
kind,
|
|
731
|
-
x:
|
|
732
|
-
y:
|
|
733
|
-
downX:
|
|
734
|
-
downY:
|
|
1317
|
+
x: event.clientX,
|
|
1318
|
+
y: event.clientY,
|
|
1319
|
+
downX: event.clientX,
|
|
1320
|
+
downY: event.clientY
|
|
735
1321
|
});
|
|
736
1322
|
begin(true);
|
|
737
1323
|
enterPinch();
|
|
@@ -740,71 +1326,71 @@ function createStageGestureController(el, host, options) {
|
|
|
740
1326
|
case "mousedrag": return;
|
|
741
1327
|
}
|
|
742
1328
|
};
|
|
743
|
-
const onWindowMove = (
|
|
744
|
-
const
|
|
745
|
-
if (!
|
|
746
|
-
if (
|
|
747
|
-
|
|
748
|
-
|
|
1329
|
+
const onWindowMove = (event) => {
|
|
1330
|
+
const pointer = pointers.get(event.pointerId);
|
|
1331
|
+
if (!pointer) return;
|
|
1332
|
+
if (pointer.kind === "touch") lastTouchAt = performance.now();
|
|
1333
|
+
pointer.x = event.clientX;
|
|
1334
|
+
pointer.y = event.clientY;
|
|
749
1335
|
switch (mode) {
|
|
750
1336
|
case "pending":
|
|
751
|
-
pushSample(
|
|
752
|
-
if (Math.hypot(
|
|
1337
|
+
pushSample(pointer.x, pointer.y);
|
|
1338
|
+
if (Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY) > SLOP) {
|
|
753
1339
|
clearLongPress();
|
|
754
1340
|
mode = "pan";
|
|
755
|
-
panId =
|
|
1341
|
+
panId = pointer.id;
|
|
756
1342
|
lastApplied = {
|
|
757
|
-
x:
|
|
758
|
-
y:
|
|
1343
|
+
x: pointer.x,
|
|
1344
|
+
y: pointer.y
|
|
759
1345
|
};
|
|
760
1346
|
ensureFrames();
|
|
761
1347
|
}
|
|
762
1348
|
break;
|
|
763
1349
|
case "pan":
|
|
764
|
-
if (
|
|
1350
|
+
if (pointer.id !== panId) break;
|
|
765
1351
|
if (!panArmed) {
|
|
766
1352
|
const nowT = performance.now();
|
|
767
1353
|
if (nowT < panGraceUntil) {
|
|
768
1354
|
const dt = nowT - graceSample.t;
|
|
769
1355
|
if (dt >= 8) {
|
|
770
|
-
const speed = Math.hypot(
|
|
1356
|
+
const speed = Math.hypot(pointer.x - graceSample.x, pointer.y - graceSample.y) / dt;
|
|
771
1357
|
graceSample = {
|
|
772
1358
|
t: nowT,
|
|
773
|
-
x:
|
|
774
|
-
y:
|
|
1359
|
+
x: pointer.x,
|
|
1360
|
+
y: pointer.y
|
|
775
1361
|
};
|
|
776
1362
|
if (speed < SETTLE_SPEED) panGraceUntil = 0;
|
|
777
1363
|
}
|
|
778
|
-
|
|
779
|
-
|
|
1364
|
+
pointer.downX = pointer.x;
|
|
1365
|
+
pointer.downY = pointer.y;
|
|
780
1366
|
break;
|
|
781
1367
|
}
|
|
782
|
-
if (Math.hypot(
|
|
1368
|
+
if (Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY) <= SLOP) break;
|
|
783
1369
|
panArmed = true;
|
|
784
1370
|
lastApplied = {
|
|
785
|
-
x:
|
|
786
|
-
y:
|
|
1371
|
+
x: pointer.x,
|
|
1372
|
+
y: pointer.y
|
|
787
1373
|
};
|
|
788
1374
|
trail = [];
|
|
789
1375
|
spanTrail = [];
|
|
790
1376
|
}
|
|
791
|
-
pushSample(
|
|
1377
|
+
pushSample(pointer.x, pointer.y);
|
|
792
1378
|
dirty = true;
|
|
793
1379
|
break;
|
|
794
1380
|
case "pinch":
|
|
795
1381
|
dirty = true;
|
|
796
1382
|
break;
|
|
797
1383
|
case "tool":
|
|
798
|
-
sink?.move(
|
|
1384
|
+
sink?.move(event);
|
|
799
1385
|
break;
|
|
800
1386
|
case "mousedrag":
|
|
801
|
-
if (
|
|
1387
|
+
if (pointer.id === panId) dirty = true;
|
|
802
1388
|
break;
|
|
803
1389
|
case "idle": break;
|
|
804
1390
|
}
|
|
805
1391
|
};
|
|
806
|
-
const onHoverMove = (
|
|
807
|
-
if (mode === "idle" && sink) sink.hover(
|
|
1392
|
+
const onHoverMove = (event) => {
|
|
1393
|
+
if (mode === "idle" && sink) sink.hover(event);
|
|
808
1394
|
};
|
|
809
1395
|
const backToSingleFinger = () => {
|
|
810
1396
|
const rest = touches();
|
|
@@ -828,28 +1414,28 @@ function createStageGestureController(el, host, options) {
|
|
|
828
1414
|
dirty = false;
|
|
829
1415
|
return true;
|
|
830
1416
|
};
|
|
831
|
-
const onUp = (
|
|
832
|
-
const
|
|
833
|
-
if (!
|
|
834
|
-
if (
|
|
835
|
-
pointers.delete(
|
|
1417
|
+
const onUp = (event) => {
|
|
1418
|
+
const pointer = pointers.get(event.pointerId);
|
|
1419
|
+
if (!pointer) return;
|
|
1420
|
+
if (pointer.kind === "touch") lastTouchAt = performance.now();
|
|
1421
|
+
pointers.delete(event.pointerId);
|
|
836
1422
|
switch (mode) {
|
|
837
1423
|
case "pending": {
|
|
838
1424
|
clearLongPress();
|
|
839
1425
|
end();
|
|
840
1426
|
const now = performance.now();
|
|
841
|
-
const isDouble = now - lastTapT <= DOUBLE_TAP_MS && Math.hypot(
|
|
1427
|
+
const isDouble = now - lastTapT <= DOUBLE_TAP_MS && Math.hypot(event.clientX - lastTapX, event.clientY - lastTapY) <= DOUBLE_TAP_RADIUS;
|
|
842
1428
|
if (suppressTap) lastTapT = 0;
|
|
843
1429
|
else if (zoomGestures && isDouble) {
|
|
844
1430
|
lastTapT = 0;
|
|
845
|
-
host.doubleTapZoom(vpt(
|
|
1431
|
+
host.doubleTapZoom(vpt(event.clientX, event.clientY));
|
|
846
1432
|
} else {
|
|
847
1433
|
lastTapT = now;
|
|
848
|
-
lastTapX =
|
|
849
|
-
lastTapY =
|
|
1434
|
+
lastTapX = event.clientX;
|
|
1435
|
+
lastTapY = event.clientY;
|
|
850
1436
|
if (sink && downEvent) {
|
|
851
1437
|
sink.down(downEvent, 1);
|
|
852
|
-
sink.up(
|
|
1438
|
+
sink.up(event);
|
|
853
1439
|
}
|
|
854
1440
|
}
|
|
855
1441
|
toIdle();
|
|
@@ -858,18 +1444,18 @@ function createStageGestureController(el, host, options) {
|
|
|
858
1444
|
case "pan":
|
|
859
1445
|
if (!panArmed) {
|
|
860
1446
|
const now = performance.now();
|
|
861
|
-
const
|
|
1447
|
+
const velocity = computeReleaseVelocity(trail, now);
|
|
862
1448
|
const vs = computeReleaseVelocity(spanTrail, now);
|
|
863
1449
|
end();
|
|
864
|
-
if (
|
|
865
|
-
const speed = Math.hypot(
|
|
866
|
-
if (speed >= FLING_MIN && speed > Math.abs(vs?.vx ?? 0)) host.fling(
|
|
1450
|
+
if (velocity) {
|
|
1451
|
+
const speed = Math.hypot(velocity.vx, velocity.vy);
|
|
1452
|
+
if (speed >= FLING_MIN && speed > Math.abs(vs?.vx ?? 0)) host.fling(velocity.vx, velocity.vy);
|
|
867
1453
|
}
|
|
868
1454
|
toIdle();
|
|
869
1455
|
break;
|
|
870
1456
|
}
|
|
871
|
-
flushPanTo(
|
|
872
|
-
pushSample(
|
|
1457
|
+
flushPanTo(event.clientX, event.clientY);
|
|
1458
|
+
pushSample(event.clientX, event.clientY);
|
|
873
1459
|
end();
|
|
874
1460
|
maybeFling();
|
|
875
1461
|
toIdle();
|
|
@@ -901,23 +1487,23 @@ function createStageGestureController(el, host, options) {
|
|
|
901
1487
|
break;
|
|
902
1488
|
}
|
|
903
1489
|
case "tool":
|
|
904
|
-
sink?.up(
|
|
1490
|
+
sink?.up(event);
|
|
905
1491
|
toIdle();
|
|
906
1492
|
break;
|
|
907
1493
|
case "mousedrag":
|
|
908
|
-
if (
|
|
909
|
-
flushPanTo(
|
|
1494
|
+
if (pointer.id !== panId) break;
|
|
1495
|
+
flushPanTo(event.clientX, event.clientY);
|
|
910
1496
|
end();
|
|
911
1497
|
toIdle();
|
|
912
1498
|
break;
|
|
913
1499
|
case "idle": break;
|
|
914
1500
|
}
|
|
915
1501
|
};
|
|
916
|
-
const onCancel = (
|
|
917
|
-
if (!pointers.get(
|
|
918
|
-
pointers.delete(
|
|
1502
|
+
const onCancel = (event) => {
|
|
1503
|
+
if (!pointers.get(event.pointerId)) return;
|
|
1504
|
+
pointers.delete(event.pointerId);
|
|
919
1505
|
if (mode === "tool") {
|
|
920
|
-
sink?.cancel(
|
|
1506
|
+
sink?.cancel(event);
|
|
921
1507
|
toIdle();
|
|
922
1508
|
return;
|
|
923
1509
|
}
|
|
@@ -925,51 +1511,51 @@ function createStageGestureController(el, host, options) {
|
|
|
925
1511
|
end();
|
|
926
1512
|
toIdle();
|
|
927
1513
|
};
|
|
928
|
-
const onWheel = (
|
|
929
|
-
|
|
930
|
-
if (zoomGestures && (
|
|
1514
|
+
const onWheel = (event) => {
|
|
1515
|
+
event.preventDefault();
|
|
1516
|
+
if (zoomGestures && (event.ctrlKey || event.metaKey)) host.zoomAround(vpt(event.clientX, event.clientY), wheelZoom(event));
|
|
931
1517
|
else {
|
|
932
|
-
const dx =
|
|
933
|
-
const dy =
|
|
1518
|
+
const dx = event.shiftKey ? event.deltaY : event.deltaX;
|
|
1519
|
+
const dy = event.shiftKey ? event.deltaX : event.deltaY;
|
|
934
1520
|
host.panBy(-dx, -dy);
|
|
935
1521
|
}
|
|
936
1522
|
};
|
|
937
1523
|
let lastScale = 1;
|
|
938
|
-
const onGestureStart = (
|
|
939
|
-
|
|
940
|
-
lastScale =
|
|
1524
|
+
const onGestureStart = (event) => {
|
|
1525
|
+
event.preventDefault();
|
|
1526
|
+
lastScale = event.scale ?? 1;
|
|
941
1527
|
};
|
|
942
|
-
const onGestureChange = (
|
|
943
|
-
|
|
1528
|
+
const onGestureChange = (event) => {
|
|
1529
|
+
event.preventDefault();
|
|
944
1530
|
if (touches().length > 0 || performance.now() - lastTouchAt < 500) return;
|
|
945
|
-
const
|
|
946
|
-
const scale =
|
|
947
|
-
if (zoomGestures && scale > 0) host.zoomAround(vpt(
|
|
1531
|
+
const gesture = event;
|
|
1532
|
+
const scale = gesture.scale ?? 1;
|
|
1533
|
+
if (zoomGestures && scale > 0) host.zoomAround(vpt(gesture.clientX, gesture.clientY), scale / lastScale);
|
|
948
1534
|
lastScale = scale;
|
|
949
1535
|
};
|
|
950
|
-
|
|
951
|
-
|
|
1536
|
+
element.addEventListener("pointerdown", onDown);
|
|
1537
|
+
element.addEventListener("pointermove", onHoverMove);
|
|
952
1538
|
window.addEventListener("pointermove", onWindowMove);
|
|
953
1539
|
window.addEventListener("pointerup", onUp);
|
|
954
1540
|
window.addEventListener("pointercancel", onCancel);
|
|
955
|
-
|
|
1541
|
+
element.addEventListener("wheel", onWheel, { passive: false });
|
|
956
1542
|
const hasGestureEvents = "GestureEvent" in window;
|
|
957
1543
|
if (hasGestureEvents) {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1544
|
+
element.addEventListener("gesturestart", onGestureStart);
|
|
1545
|
+
element.addEventListener("gesturechange", onGestureChange);
|
|
1546
|
+
element.addEventListener("gestureend", onGestureStart);
|
|
961
1547
|
}
|
|
962
1548
|
return () => {
|
|
963
|
-
|
|
964
|
-
|
|
1549
|
+
element.removeEventListener("pointerdown", onDown);
|
|
1550
|
+
element.removeEventListener("pointermove", onHoverMove);
|
|
965
1551
|
window.removeEventListener("pointermove", onWindowMove);
|
|
966
1552
|
window.removeEventListener("pointerup", onUp);
|
|
967
1553
|
window.removeEventListener("pointercancel", onCancel);
|
|
968
|
-
|
|
1554
|
+
element.removeEventListener("wheel", onWheel);
|
|
969
1555
|
if (hasGestureEvents) {
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1556
|
+
element.removeEventListener("gesturestart", onGestureStart);
|
|
1557
|
+
element.removeEventListener("gesturechange", onGestureChange);
|
|
1558
|
+
element.removeEventListener("gestureend", onGestureStart);
|
|
973
1559
|
}
|
|
974
1560
|
clearLongPress();
|
|
975
1561
|
stopFrames();
|
|
@@ -979,49 +1565,49 @@ function createStageGestureController(el, host, options) {
|
|
|
979
1565
|
//#endregion
|
|
980
1566
|
//#region src/selection-handles.ts
|
|
981
1567
|
/** Bind one handle element. Returns the detach fn. */
|
|
982
|
-
function attachSelectionHandle(
|
|
1568
|
+
function attachSelectionHandle(element, options) {
|
|
983
1569
|
let active = null;
|
|
984
|
-
const onMove = (
|
|
985
|
-
if (!active ||
|
|
1570
|
+
const onMove = (event) => {
|
|
1571
|
+
if (!active || event.pointerId !== active.pointerId) return;
|
|
986
1572
|
active.session.move({
|
|
987
|
-
x: active.base.x + (
|
|
988
|
-
y: active.base.y + (
|
|
1573
|
+
x: active.base.x + (event.clientX - active.baseClient.x),
|
|
1574
|
+
y: active.base.y + (event.clientY - active.baseClient.y)
|
|
989
1575
|
});
|
|
990
1576
|
};
|
|
991
|
-
const onUp = (
|
|
992
|
-
if (!active ||
|
|
993
|
-
const
|
|
1577
|
+
const onUp = (event) => {
|
|
1578
|
+
if (!active || event.pointerId !== active.pointerId) return;
|
|
1579
|
+
const session = active.session;
|
|
994
1580
|
active = null;
|
|
995
|
-
|
|
1581
|
+
session.end();
|
|
996
1582
|
};
|
|
997
|
-
const onDown = (
|
|
1583
|
+
const onDown = (event) => {
|
|
998
1584
|
if (active) return;
|
|
999
1585
|
const armed = options.arm();
|
|
1000
1586
|
if (!armed) return;
|
|
1001
|
-
|
|
1002
|
-
|
|
1587
|
+
event.preventDefault();
|
|
1588
|
+
event.stopPropagation();
|
|
1003
1589
|
try {
|
|
1004
|
-
|
|
1590
|
+
element.setPointerCapture(event.pointerId);
|
|
1005
1591
|
} catch {}
|
|
1006
1592
|
active = {
|
|
1007
1593
|
session: armed.session,
|
|
1008
1594
|
base: armed.base,
|
|
1009
1595
|
baseClient: {
|
|
1010
|
-
x:
|
|
1011
|
-
y:
|
|
1596
|
+
x: event.clientX,
|
|
1597
|
+
y: event.clientY
|
|
1012
1598
|
},
|
|
1013
|
-
pointerId:
|
|
1599
|
+
pointerId: event.pointerId
|
|
1014
1600
|
};
|
|
1015
1601
|
};
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1602
|
+
element.addEventListener("pointerdown", onDown);
|
|
1603
|
+
element.addEventListener("pointermove", onMove);
|
|
1604
|
+
element.addEventListener("pointerup", onUp);
|
|
1605
|
+
element.addEventListener("pointercancel", onUp);
|
|
1020
1606
|
return () => {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1607
|
+
element.removeEventListener("pointerdown", onDown);
|
|
1608
|
+
element.removeEventListener("pointermove", onMove);
|
|
1609
|
+
element.removeEventListener("pointerup", onUp);
|
|
1610
|
+
element.removeEventListener("pointercancel", onUp);
|
|
1025
1611
|
active = null;
|
|
1026
1612
|
};
|
|
1027
1613
|
}
|
|
@@ -1036,7 +1622,7 @@ function attachSelectionHandle(el, options) {
|
|
|
1036
1622
|
* stage plugin's job)
|
|
1037
1623
|
* • device-pixel-ratio observation → `setDevicePixelRatio` (dppx changes
|
|
1038
1624
|
* re-subscribe, since the media query's value itself moves)
|
|
1039
|
-
* • pointer-sample normalization — the
|
|
1625
|
+
* • pointer-sample normalization — the one place a PointerEvent becomes a
|
|
1040
1626
|
* page-resolved, source-stamped sample for the interaction hub
|
|
1041
1627
|
* • the gesture controller (wheel, Safari trackpad gestures, synthesized
|
|
1042
1628
|
* touch physics) from `./stage-gestures`
|
|
@@ -1045,24 +1631,24 @@ function attachSelectionHandle(el, options) {
|
|
|
1045
1631
|
* surfaces, cursor-style subscription, overlay slots.
|
|
1046
1632
|
*
|
|
1047
1633
|
* Dependency note: like the gesture controller, this module speaks to the
|
|
1048
|
-
* stage and the hub through
|
|
1634
|
+
* stage and the hub through structural interfaces ({@link StageSurfaceHost},
|
|
1049
1635
|
* {@link StageSurfaceHub}) — satisfied by `StageCapability` and
|
|
1050
|
-
* `
|
|
1636
|
+
* `InteractionHostCapability`, imported by neither. @embedpdf/web stays free of
|
|
1051
1637
|
* plugin imports, per the layering law.
|
|
1052
1638
|
*
|
|
1053
1639
|
* Browser-only by nature (ResizeObserver, matchMedia): call from a mounted
|
|
1054
1640
|
* effect / browser platform check, exactly like the gesture controller.
|
|
1055
1641
|
*/
|
|
1056
1642
|
/** Attach the full surface binding. Returns the detach fn. */
|
|
1057
|
-
function createStageSurface(
|
|
1643
|
+
function createStageSurface(element, stage, options = {}) {
|
|
1058
1644
|
const hub = options.hub ?? null;
|
|
1059
1645
|
const cleanups = [];
|
|
1060
|
-
const setViewport = () => stage.
|
|
1061
|
-
width:
|
|
1062
|
-
height:
|
|
1646
|
+
const setViewport = () => stage.setViewportSize({
|
|
1647
|
+
width: element.clientWidth,
|
|
1648
|
+
height: element.clientHeight
|
|
1063
1649
|
});
|
|
1064
1650
|
const ro = new ResizeObserver(setViewport);
|
|
1065
|
-
ro.observe(
|
|
1651
|
+
ro.observe(element);
|
|
1066
1652
|
setViewport();
|
|
1067
1653
|
cleanups.push(() => ro.disconnect());
|
|
1068
1654
|
let mq = null;
|
|
@@ -1074,42 +1660,42 @@ function createStageSurface(el, stage, options = {}) {
|
|
|
1074
1660
|
};
|
|
1075
1661
|
reportDpr();
|
|
1076
1662
|
cleanups.push(() => mq?.removeEventListener("change", reportDpr));
|
|
1077
|
-
const sampleOf = (phase,
|
|
1078
|
-
const
|
|
1663
|
+
const sampleOf = (phase, event, clickCount = 1, gesture) => {
|
|
1664
|
+
const rect = element.getBoundingClientRect();
|
|
1079
1665
|
const viewport = {
|
|
1080
|
-
x:
|
|
1081
|
-
y:
|
|
1666
|
+
x: event.clientX - rect.left,
|
|
1667
|
+
y: event.clientY - rect.top
|
|
1082
1668
|
};
|
|
1083
1669
|
return {
|
|
1084
1670
|
phase,
|
|
1085
1671
|
viewport,
|
|
1086
|
-
page: stage.
|
|
1087
|
-
project: (
|
|
1672
|
+
page: stage.getPageAt(viewport) ?? void 0,
|
|
1673
|
+
project: (page) => stage.viewportToPage(page, viewport),
|
|
1088
1674
|
modifiers: {
|
|
1089
|
-
shift:
|
|
1090
|
-
alt:
|
|
1091
|
-
ctrl:
|
|
1092
|
-
meta:
|
|
1675
|
+
shift: event.shiftKey,
|
|
1676
|
+
alt: event.altKey,
|
|
1677
|
+
ctrl: event.ctrlKey,
|
|
1678
|
+
meta: event.metaKey
|
|
1093
1679
|
},
|
|
1094
1680
|
clickCount,
|
|
1095
|
-
pointerType:
|
|
1681
|
+
pointerType: event.pointerType || "mouse",
|
|
1096
1682
|
...gesture ? { gesture } : {},
|
|
1097
1683
|
...options.source ? { source: options.source } : {}
|
|
1098
1684
|
};
|
|
1099
1685
|
};
|
|
1100
|
-
const forward = (phase,
|
|
1101
|
-
hub?.
|
|
1686
|
+
const forward = (phase, event, clickCount = 1, gesture) => {
|
|
1687
|
+
hub?.dispatchPointer(sampleOf(phase, event, clickCount, gesture));
|
|
1102
1688
|
};
|
|
1103
1689
|
const sink = hub ? {
|
|
1104
|
-
down: (
|
|
1105
|
-
move: (
|
|
1106
|
-
up: (
|
|
1107
|
-
cancel: (
|
|
1108
|
-
hover: (
|
|
1109
|
-
longPress: (
|
|
1110
|
-
claimsPoint: (
|
|
1690
|
+
down: (event, clickCount) => forward("down", event, clickCount),
|
|
1691
|
+
move: (event) => forward("move", event),
|
|
1692
|
+
up: (event) => forward("up", event),
|
|
1693
|
+
cancel: (event) => forward("cancel", event),
|
|
1694
|
+
hover: (event) => forward("move", event),
|
|
1695
|
+
longPress: (event) => forward("down", event, 2, "long-press"),
|
|
1696
|
+
claimsPoint: (event) => hub.getActiveTool().touch === "draw" || hub.wouldClaimTouch(sampleOf("down", event))
|
|
1111
1697
|
} : null;
|
|
1112
|
-
cleanups.push(createStageGestureController(
|
|
1698
|
+
cleanups.push(createStageGestureController(element, stage, {
|
|
1113
1699
|
zoomGestures: options.zoomGestures,
|
|
1114
1700
|
wheelZoomFactor: options.wheelZoomFactor,
|
|
1115
1701
|
sink
|
|
@@ -1121,8 +1707,8 @@ function createStageSurface(el, stage, options = {}) {
|
|
|
1121
1707
|
//#endregion
|
|
1122
1708
|
//#region src/byte-store.ts
|
|
1123
1709
|
/** Open (creating on first use) one IndexedDB object store keyed by id. */
|
|
1124
|
-
function indexedDbByteStore(dbName,
|
|
1125
|
-
const storeName =
|
|
1710
|
+
function indexedDbByteStore(dbName, options = {}) {
|
|
1711
|
+
const storeName = options.storeName ?? "bytes";
|
|
1126
1712
|
const open = () => new Promise((resolve, reject) => {
|
|
1127
1713
|
const request = indexedDB.open(dbName, 1);
|
|
1128
1714
|
request.onupgradeneeded = () => {
|
|
@@ -1158,34 +1744,51 @@ function indexedDbByteStore(dbName, opts = {}) {
|
|
|
1158
1744
|
}
|
|
1159
1745
|
//#endregion
|
|
1160
1746
|
//#region src/web-font.ts
|
|
1747
|
+
/** What every family {@link mountWebFont} mounts starts with. */
|
|
1748
|
+
const FAMILY_PREFIX = "epdf-";
|
|
1749
|
+
/**
|
|
1750
|
+
* The CSS family a registered font's key is mounted under: the key in the
|
|
1751
|
+
* viewer's own namespace, so a family of the same name on the page (its own
|
|
1752
|
+
* `@font-face`, or an installed font) never stands in for it. The annotation
|
|
1753
|
+
* plugin's CSS for a registered font names the same family.
|
|
1754
|
+
*/
|
|
1755
|
+
function webFontFamily(key) {
|
|
1756
|
+
return `${FAMILY_PREFIX}${key}`;
|
|
1757
|
+
}
|
|
1758
|
+
/** The key a family from {@link webFontFamily} was made from; any other family as it is. */
|
|
1759
|
+
function fontKeyOfWebFamily(family) {
|
|
1760
|
+
return family.startsWith(FAMILY_PREFIX) ? family.slice(5) : family;
|
|
1761
|
+
}
|
|
1161
1762
|
/**
|
|
1162
1763
|
* Mount a registered font for the DOM: the engine lays text out with the
|
|
1163
1764
|
* font's bytes, the browser needs the same face as a `@font-face` so the
|
|
1164
1765
|
* live editor and the vector renderer show the glyphs the appearance stream
|
|
1165
|
-
* will bake. The family
|
|
1166
|
-
* annotation plugin emits for a registered font (`cssFontFamily`) — so
|
|
1167
|
-
* mounting under the key is what makes the two agree.
|
|
1766
|
+
* will bake. The face's family is {@link webFontFamily} of the key.
|
|
1168
1767
|
*
|
|
1169
1768
|
* Returns the unmount. Idempotent per (document, key): a second mount of a
|
|
1170
1769
|
* key already in the document's font set is a no-op that still returns an
|
|
1171
|
-
* unmount of its own (the face leaves when the
|
|
1770
|
+
* unmount of its own (the face leaves when the last holder unmounts).
|
|
1172
1771
|
*/
|
|
1173
1772
|
async function mountWebFont(key, data, options = {}) {
|
|
1773
|
+
return mountFace(webFontFamily(key), data, options);
|
|
1774
|
+
}
|
|
1775
|
+
/** Mount one face under `family`, refcounted per (document, family). */
|
|
1776
|
+
async function mountFace(family, data, options) {
|
|
1174
1777
|
const doc = options.document ?? document;
|
|
1175
1778
|
const fonts = doc.fonts;
|
|
1176
1779
|
if (!fonts || typeof FontFace === "undefined") return () => {};
|
|
1177
1780
|
const holders = holdersOf(doc);
|
|
1178
|
-
const existing = holders.get(
|
|
1781
|
+
const existing = holders.get(family);
|
|
1179
1782
|
if (existing) {
|
|
1180
1783
|
existing.count++;
|
|
1181
|
-
return () => release(holders,
|
|
1784
|
+
return () => release(holders, family);
|
|
1182
1785
|
}
|
|
1183
1786
|
const buffer = data instanceof Uint8Array ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : data;
|
|
1184
|
-
const face = new FontFace(
|
|
1787
|
+
const face = new FontFace(family, buffer, {
|
|
1185
1788
|
weight: options.weight !== void 0 ? String(options.weight) : void 0,
|
|
1186
1789
|
style: options.style
|
|
1187
1790
|
});
|
|
1188
|
-
holders.set(
|
|
1791
|
+
holders.set(family, {
|
|
1189
1792
|
face,
|
|
1190
1793
|
doc,
|
|
1191
1794
|
count: 1
|
|
@@ -1194,11 +1797,47 @@ async function mountWebFont(key, data, options = {}) {
|
|
|
1194
1797
|
await face.load();
|
|
1195
1798
|
fonts.add(face);
|
|
1196
1799
|
} catch {
|
|
1197
|
-
holders.delete(
|
|
1800
|
+
holders.delete(family);
|
|
1198
1801
|
return () => {};
|
|
1199
1802
|
}
|
|
1200
1803
|
notifyFontChange(doc);
|
|
1201
|
-
return () => release(holders,
|
|
1804
|
+
return () => release(holders, family);
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Mount a program under its own family, as it is (already in the viewer's
|
|
1808
|
+
* namespace), in its own weight and style, so CSS asking for that style takes
|
|
1809
|
+
* it as it is and never fakes a bold or an italic. Refcounted per document
|
|
1810
|
+
* like {@link mountWebFont}; resolves with the unmount.
|
|
1811
|
+
*/
|
|
1812
|
+
async function mountFontProgram(program, options = {}) {
|
|
1813
|
+
const bytes = await program.load();
|
|
1814
|
+
return mountFace(program.family, bytes, {
|
|
1815
|
+
...options,
|
|
1816
|
+
weight: program.weight,
|
|
1817
|
+
style: program.style
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* The programs one layer has mounted: each mounted once, however often it's
|
|
1822
|
+
* asked for, and all let go together when the layer goes (a program another
|
|
1823
|
+
* layer holds stays, refcounted per document). A mount after the release
|
|
1824
|
+
* holds again, for a layer that comes back (React's strict mode runs effects
|
|
1825
|
+
* twice).
|
|
1826
|
+
*/
|
|
1827
|
+
function createFontMounts(options = {}) {
|
|
1828
|
+
const held = /* @__PURE__ */ new Map();
|
|
1829
|
+
return {
|
|
1830
|
+
mount(programs) {
|
|
1831
|
+
for (const program of programs) {
|
|
1832
|
+
if (held.has(program.family)) continue;
|
|
1833
|
+
held.set(program.family, mountFontProgram(program, options).catch(() => () => {}));
|
|
1834
|
+
}
|
|
1835
|
+
},
|
|
1836
|
+
release() {
|
|
1837
|
+
for (const unmount of held.values()) unmount.then((off) => off());
|
|
1838
|
+
held.clear();
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1202
1841
|
}
|
|
1203
1842
|
const HOLDERS = /* @__PURE__ */ new WeakMap();
|
|
1204
1843
|
const LISTENERS = /* @__PURE__ */ new WeakMap();
|
|
@@ -1230,12 +1869,12 @@ function holdersOf(doc) {
|
|
|
1230
1869
|
}
|
|
1231
1870
|
return map;
|
|
1232
1871
|
}
|
|
1233
|
-
function release(holders,
|
|
1234
|
-
const holder = holders.get(
|
|
1872
|
+
function release(holders, family) {
|
|
1873
|
+
const holder = holders.get(family);
|
|
1235
1874
|
if (!holder) return;
|
|
1236
1875
|
holder.count--;
|
|
1237
1876
|
if (holder.count > 0) return;
|
|
1238
|
-
holders.delete(
|
|
1877
|
+
holders.delete(family);
|
|
1239
1878
|
try {
|
|
1240
1879
|
holder.doc.fonts.delete(holder.face);
|
|
1241
1880
|
} catch {}
|
|
@@ -1311,11 +1950,11 @@ function webFontMetrics(cssFamily, doc = typeof document === "undefined" ? void
|
|
|
1311
1950
|
CONTEXTS.set(doc, ctx);
|
|
1312
1951
|
}
|
|
1313
1952
|
ctx.font = `${face.style ?? "normal"} ${face.weight ?? 400} 100px ${cssFamily}`;
|
|
1314
|
-
const
|
|
1315
|
-
if (typeof
|
|
1953
|
+
const metrics = ctx.measureText("Hg");
|
|
1954
|
+
if (typeof metrics.fontBoundingBoxAscent !== "number" || typeof metrics.fontBoundingBoxDescent !== "number") return null;
|
|
1316
1955
|
return {
|
|
1317
|
-
ascent:
|
|
1318
|
-
descent:
|
|
1956
|
+
ascent: metrics.fontBoundingBoxAscent / 100,
|
|
1957
|
+
descent: metrics.fontBoundingBoxDescent / 100
|
|
1319
1958
|
};
|
|
1320
1959
|
}
|
|
1321
1960
|
/**
|
|
@@ -1348,7 +1987,7 @@ function firstLineShiftFor(cssFamily, fontSize, measure = webFontMetrics) {
|
|
|
1348
1987
|
//#endregion
|
|
1349
1988
|
//#region src/rich-text-editor.ts
|
|
1350
1989
|
/**
|
|
1351
|
-
* Rich text editor binding — the
|
|
1990
|
+
* Rich text editor binding — the one contentEditable mechanics module for a
|
|
1352
1991
|
* FreeText annotation, shared by every framework adapter (React, Angular,
|
|
1353
1992
|
* Vue, Svelte…) so editing can never drift between them:
|
|
1354
1993
|
*
|
|
@@ -1361,7 +2000,7 @@ function firstLineShiftFor(cssFamily, fontSize, measure = webFontMetrics) {
|
|
|
1361
2000
|
* • hold re-renders during IME composition, paste as plain text, forward
|
|
1362
2001
|
* Cmd/Ctrl+B/I/U as commands
|
|
1363
2002
|
*
|
|
1364
|
-
* The browser owns typing; this module only translates. The
|
|
2003
|
+
* The browser owns typing; this module only translates. The policy — what a
|
|
1365
2004
|
* command does to the document, when to commit, what a family maps to —
|
|
1366
2005
|
* belongs to the host (the annotation plugin), reached through the
|
|
1367
2006
|
* structural {@link RichTextEditorHost}: @embedpdf/web imports no plugin,
|
|
@@ -1372,9 +2011,9 @@ function firstLineShiftFor(cssFamily, fontSize, measure = webFontMetrics) {
|
|
|
1372
2011
|
* runs concatenated, a hard break inside a run is a `\r` too. The same
|
|
1373
2012
|
* arithmetic the core's run algebra uses.
|
|
1374
2013
|
*
|
|
1375
|
-
* Known limits
|
|
1376
|
-
*
|
|
1377
|
-
* the
|
|
2014
|
+
* Known limits, by design: the browser's native undo stack does not span a
|
|
2015
|
+
* host-driven restyle; DOM line wrapping approximates the engine's layout,
|
|
2016
|
+
* the appearance stream is the truth.
|
|
1378
2017
|
*/
|
|
1379
2018
|
const ELEMENT_NODE = 1;
|
|
1380
2019
|
const TEXT_NODE = 3;
|
|
@@ -1395,16 +2034,16 @@ function lastChild(node) {
|
|
|
1395
2034
|
* Chrome does for a new styled line. Every other break is a hard line break.
|
|
1396
2035
|
*/
|
|
1397
2036
|
function isTrailingBreak(node, root) {
|
|
1398
|
-
for (let
|
|
1399
|
-
const parent =
|
|
2037
|
+
for (let current = node;;) {
|
|
2038
|
+
const parent = current.parentNode;
|
|
1400
2039
|
if (!parent) return false;
|
|
1401
|
-
if (lastChild(parent) !==
|
|
2040
|
+
if (lastChild(parent) !== current) return false;
|
|
1402
2041
|
if (parent === root || isBlock(parent)) return true;
|
|
1403
|
-
|
|
2042
|
+
current = parent;
|
|
1404
2043
|
}
|
|
1405
2044
|
}
|
|
1406
2045
|
function contains(root, node) {
|
|
1407
|
-
for (let
|
|
2046
|
+
for (let current = node; current; current = current.parentNode) if (current === root) return true;
|
|
1408
2047
|
return false;
|
|
1409
2048
|
}
|
|
1410
2049
|
const HEX = /^#([0-9a-f]{6})$/i;
|
|
@@ -1412,17 +2051,17 @@ const RGB = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i;
|
|
|
1412
2051
|
function cssColorToHex(css) {
|
|
1413
2052
|
const trimmed = css.trim();
|
|
1414
2053
|
if (HEX.test(trimmed)) return trimmed.toUpperCase();
|
|
1415
|
-
const
|
|
1416
|
-
if (!
|
|
1417
|
-
const hex = (
|
|
1418
|
-
return `#${hex(
|
|
2054
|
+
const match = RGB.exec(trimmed);
|
|
2055
|
+
if (!match) return void 0;
|
|
2056
|
+
const hex = (channel) => Math.max(0, Math.min(255, Number(channel))).toString(16).padStart(2, "0");
|
|
2057
|
+
return `#${hex(match[1])}${hex(match[2])}${hex(match[3])}`.toUpperCase();
|
|
1419
2058
|
}
|
|
1420
2059
|
function firstFamily(css) {
|
|
1421
2060
|
const first = css.split(",")[0]?.trim().replace(/^["']|["']$/g, "");
|
|
1422
2061
|
return first ? first : void 0;
|
|
1423
2062
|
}
|
|
1424
|
-
function round2(
|
|
1425
|
-
return Math.round(
|
|
2063
|
+
function round2(value) {
|
|
2064
|
+
return Math.round(value * 100) / 100;
|
|
1426
2065
|
}
|
|
1427
2066
|
/** The inline style declarations a span carries, as a style delta. Only what
|
|
1428
2067
|
* the span itself sets: an unset property means "inherit", never a value. */
|
|
@@ -1436,7 +2075,7 @@ function styleDeltaOf(style, scale) {
|
|
|
1436
2075
|
}
|
|
1437
2076
|
if (style.fontStyle) delta.italic = style.fontStyle === "italic" || style.fontStyle === "oblique";
|
|
1438
2077
|
const decoration = style.textDecorationLine || style.textDecoration;
|
|
1439
|
-
if (decoration) delta.decoration = decoration.split(/\s+/).filter((
|
|
2078
|
+
if (decoration) delta.decoration = decoration.split(/\s+/).filter((keyword) => keyword === "underline" || keyword === "line-through");
|
|
1440
2079
|
if (style.color) {
|
|
1441
2080
|
const hex = cssColorToHex(style.color);
|
|
1442
2081
|
if (hex) delta.color = hex;
|
|
@@ -1449,7 +2088,7 @@ function styleDeltaOf(style, scale) {
|
|
|
1449
2088
|
}
|
|
1450
2089
|
if (style.fontFamily) {
|
|
1451
2090
|
const family = firstFamily(style.fontFamily);
|
|
1452
|
-
if (family) delta.family = family;
|
|
2091
|
+
if (family) delta.family = fontKeyOfWebFamily(family);
|
|
1453
2092
|
}
|
|
1454
2093
|
if (style.letterSpacing) {
|
|
1455
2094
|
const px = parseFloat(style.letterSpacing);
|
|
@@ -1461,7 +2100,7 @@ function applyStyleDelta(style, delta, scale, cssFontFamily) {
|
|
|
1461
2100
|
if (delta.weight !== void 0) style.fontWeight = String(delta.weight);
|
|
1462
2101
|
if (delta.italic !== void 0) style.fontStyle = delta.italic ? "italic" : "normal";
|
|
1463
2102
|
if (delta.decoration !== void 0) {
|
|
1464
|
-
const lines = delta.decoration.map((
|
|
2103
|
+
const lines = delta.decoration.map((decoration) => decoration === "word" ? "underline" : decoration);
|
|
1465
2104
|
style.textDecoration = lines.length ? [...new Set(lines)].join(" ") : "none";
|
|
1466
2105
|
}
|
|
1467
2106
|
if (delta.color !== void 0) style.color = delta.color;
|
|
@@ -1478,9 +2117,9 @@ function mergeDelta(base, over) {
|
|
|
1478
2117
|
...over
|
|
1479
2118
|
};
|
|
1480
2119
|
}
|
|
1481
|
-
function sameDelta(
|
|
1482
|
-
const da =
|
|
1483
|
-
const db =
|
|
2120
|
+
function sameDelta(left, right) {
|
|
2121
|
+
const da = left ?? {};
|
|
2122
|
+
const db = right ?? {};
|
|
1484
2123
|
const keys = /* @__PURE__ */ new Set([...Object.keys(da), ...Object.keys(db)]);
|
|
1485
2124
|
for (const key of keys) {
|
|
1486
2125
|
const va = da[key];
|
|
@@ -1656,25 +2295,25 @@ function offsetOfPosition(root, position) {
|
|
|
1656
2295
|
if (!contains(root, position.node)) return 0;
|
|
1657
2296
|
const events = collectOffsets(root);
|
|
1658
2297
|
if (position.node.nodeType === TEXT_NODE) {
|
|
1659
|
-
const event = events.find((
|
|
2298
|
+
const event = events.find((event) => event.kind === "text" && event.node === position.node);
|
|
1660
2299
|
return event ? event.at + Math.min(position.offset, event.length) : 0;
|
|
1661
2300
|
}
|
|
1662
2301
|
if (isBreak(position.node)) {
|
|
1663
|
-
const event = events.find((
|
|
2302
|
+
const event = events.find((event) => event.kind === "break" && event.node === position.node);
|
|
1664
2303
|
if (event) return event.at + (position.offset > 0 ? 1 : 0);
|
|
1665
|
-
const exit = events.find((
|
|
2304
|
+
const exit = events.find((event) => event.kind === "exit" && event.node === position.node.parentNode);
|
|
1666
2305
|
return exit ? exit.at : 0;
|
|
1667
2306
|
}
|
|
1668
2307
|
const child = position.node.childNodes[position.offset];
|
|
1669
2308
|
if (child) {
|
|
1670
|
-
const event = events.find((
|
|
2309
|
+
const event = events.find((event) => event.node === child || event.kind !== "exit" && contains(child, event.node));
|
|
1671
2310
|
if (event) return event.at;
|
|
1672
2311
|
}
|
|
1673
2312
|
if (position.node === root) {
|
|
1674
2313
|
const last = events[events.length - 1];
|
|
1675
2314
|
return last ? last.at + last.length : 0;
|
|
1676
2315
|
}
|
|
1677
|
-
const exit = events.find((
|
|
2316
|
+
const exit = events.find((event) => event.kind === "exit" && event.node === position.node);
|
|
1678
2317
|
return exit ? exit.at : 0;
|
|
1679
2318
|
}
|
|
1680
2319
|
function indexIn(parent, node) {
|
|
@@ -1685,26 +2324,26 @@ function indexIn(parent, node) {
|
|
|
1685
2324
|
* else the empty block / the slot after a hard break the offset names. */
|
|
1686
2325
|
function positionOfOffset(root, offset) {
|
|
1687
2326
|
const events = collectOffsets(root);
|
|
1688
|
-
const text = events.find((
|
|
2327
|
+
const text = events.find((event) => event.kind === "text" && offset >= event.at && offset <= event.at + event.length);
|
|
1689
2328
|
if (text) return {
|
|
1690
2329
|
node: text.node,
|
|
1691
2330
|
offset: offset - text.at
|
|
1692
2331
|
};
|
|
1693
|
-
const block = events.find((
|
|
2332
|
+
const block = events.find((event) => event.kind === "enter" && isBlock(event.node) && event.at === offset);
|
|
1694
2333
|
if (block) return {
|
|
1695
2334
|
node: block.node,
|
|
1696
2335
|
offset: 0
|
|
1697
2336
|
};
|
|
1698
|
-
const after = events.find((
|
|
2337
|
+
const after = events.find((event) => event.kind === "break" && event.at + 1 === offset);
|
|
1699
2338
|
if (after && after.node.parentNode) return {
|
|
1700
2339
|
node: after.node.parentNode,
|
|
1701
2340
|
offset: indexIn(after.node.parentNode, after.node) + 1
|
|
1702
2341
|
};
|
|
1703
2342
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
1704
|
-
const
|
|
1705
|
-
if (
|
|
1706
|
-
node:
|
|
1707
|
-
offset:
|
|
2343
|
+
const event = events[i];
|
|
2344
|
+
if (event.kind === "text") return {
|
|
2345
|
+
node: event.node,
|
|
2346
|
+
offset: event.length
|
|
1708
2347
|
};
|
|
1709
2348
|
}
|
|
1710
2349
|
const first = root.childNodes[0];
|
|
@@ -1715,20 +2354,20 @@ function positionOfOffset(root, offset) {
|
|
|
1715
2354
|
}
|
|
1716
2355
|
/** The selection's range as the shadow tree sees it: the standard
|
|
1717
2356
|
* `getComposedRanges` (options form, then the earlier positional form). */
|
|
1718
|
-
function composedRange(
|
|
1719
|
-
const
|
|
1720
|
-
if (typeof
|
|
2357
|
+
function composedRange(selection, shadowRoot) {
|
|
2358
|
+
const composable = selection;
|
|
2359
|
+
if (typeof composable.getComposedRanges !== "function") return null;
|
|
1721
2360
|
for (const args of [[{ shadowRoots: [shadowRoot] }], [shadowRoot]]) try {
|
|
1722
|
-
const ranges =
|
|
2361
|
+
const ranges = composable.getComposedRanges(...args);
|
|
1723
2362
|
if (ranges.length) return ranges[0];
|
|
1724
2363
|
} catch {}
|
|
1725
2364
|
return null;
|
|
1726
2365
|
}
|
|
1727
|
-
function documentsEqual(
|
|
1728
|
-
if (
|
|
1729
|
-
for (let i = 0; i <
|
|
1730
|
-
const pa =
|
|
1731
|
-
const pb =
|
|
2366
|
+
function documentsEqual(left, right) {
|
|
2367
|
+
if (left.paragraphs.length !== right.paragraphs.length) return false;
|
|
2368
|
+
for (let i = 0; i < left.paragraphs.length; i++) {
|
|
2369
|
+
const pa = left.paragraphs[i];
|
|
2370
|
+
const pb = right.paragraphs[i];
|
|
1732
2371
|
if ((pa.align ?? "left") !== (pb.align ?? "left") || (pa.dir ?? "ltr") !== (pb.dir ?? "ltr")) return false;
|
|
1733
2372
|
if (pa.runs.length !== pb.runs.length) return false;
|
|
1734
2373
|
for (let j = 0; j < pa.runs.length; j++) {
|
|
@@ -1740,21 +2379,21 @@ function documentsEqual(a, b) {
|
|
|
1740
2379
|
return true;
|
|
1741
2380
|
}
|
|
1742
2381
|
/** Bind one contentEditable element. Returns the Svelte-action-shaped handle. */
|
|
1743
|
-
function attachRichTextEditor(
|
|
1744
|
-
const root =
|
|
1745
|
-
const doc =
|
|
1746
|
-
const rootNode =
|
|
2382
|
+
function attachRichTextEditor(element, host, initial) {
|
|
2383
|
+
const root = element;
|
|
2384
|
+
const doc = element.ownerDocument;
|
|
2385
|
+
const rootNode = element.getRootNode();
|
|
1747
2386
|
const activeElement = () => rootNode.activeElement;
|
|
1748
2387
|
const liveRange = () => {
|
|
1749
|
-
const
|
|
1750
|
-
if (!
|
|
2388
|
+
const selection = doc.getSelection();
|
|
2389
|
+
if (!selection) return null;
|
|
1751
2390
|
if (rootNode !== doc) {
|
|
1752
|
-
const composed = composedRange(
|
|
2391
|
+
const composed = composedRange(selection, rootNode);
|
|
1753
2392
|
if (composed) return composed;
|
|
1754
2393
|
const shadowSel = rootNode.getSelection?.();
|
|
1755
2394
|
if (shadowSel && shadowSel.rangeCount > 0) return shadowSel.getRangeAt(0);
|
|
1756
2395
|
}
|
|
1757
|
-
return
|
|
2396
|
+
return selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
|
1758
2397
|
};
|
|
1759
2398
|
let props = initial;
|
|
1760
2399
|
let rendered = null;
|
|
@@ -1779,23 +2418,23 @@ function attachRichTextEditor(el, host, initial) {
|
|
|
1779
2418
|
};
|
|
1780
2419
|
};
|
|
1781
2420
|
const select = (range) => {
|
|
1782
|
-
const
|
|
1783
|
-
if (!
|
|
2421
|
+
const selection = doc.getSelection();
|
|
2422
|
+
if (!selection) return;
|
|
1784
2423
|
const start = positionOfOffset(root, range.start);
|
|
1785
2424
|
const end = positionOfOffset(root, range.end);
|
|
1786
2425
|
if (!start || !end) return;
|
|
1787
|
-
if (typeof
|
|
1788
|
-
|
|
2426
|
+
if (typeof selection.setBaseAndExtent === "function") {
|
|
2427
|
+
selection.setBaseAndExtent(start.node, start.offset, end.node, end.offset);
|
|
1789
2428
|
return;
|
|
1790
2429
|
}
|
|
1791
2430
|
const domRange = doc.createRange();
|
|
1792
2431
|
domRange.setStart(start.node, start.offset);
|
|
1793
2432
|
domRange.setEnd(end.node, end.offset);
|
|
1794
|
-
|
|
1795
|
-
|
|
2433
|
+
selection.removeAllRanges();
|
|
2434
|
+
selection.addRange(domRange);
|
|
1796
2435
|
};
|
|
1797
2436
|
const refreshLineModel = () => {
|
|
1798
|
-
const bodyStyle = doc.defaultView?.getComputedStyle(
|
|
2437
|
+
const bodyStyle = doc.defaultView?.getComputedStyle(element) ?? element.style;
|
|
1799
2438
|
const body = {
|
|
1800
2439
|
family: bodyStyle.fontFamily || "Helvetica",
|
|
1801
2440
|
weight: bodyStyle.fontWeight || "400",
|
|
@@ -1812,7 +2451,7 @@ function attachRichTextEditor(el, host, initial) {
|
|
|
1812
2451
|
if (!measured.has(key)) measured.set(key, webFontMetrics(family, doc, face));
|
|
1813
2452
|
return measured.get(key);
|
|
1814
2453
|
};
|
|
1815
|
-
const runs = Array.from(
|
|
2454
|
+
const runs = Array.from(element.querySelectorAll("span, b, strong, i, em")).map((node) => {
|
|
1816
2455
|
const style = doc.defaultView?.getComputedStyle(node) ?? node.style;
|
|
1817
2456
|
const face = {
|
|
1818
2457
|
family: style.fontFamily || body.family,
|
|
@@ -1827,7 +2466,7 @@ function attachRichTextEditor(el, host, initial) {
|
|
|
1827
2466
|
const measure = measureFor(body);
|
|
1828
2467
|
const lineHeight = lineModelFor(body.family, measure).lineHeight;
|
|
1829
2468
|
const shift = firstLineShiftFor(body.family, size, measure);
|
|
1830
|
-
|
|
2469
|
+
element.style.lineHeight = String(lineHeight);
|
|
1831
2470
|
for (const run of runs) run.node.style.lineHeight = String(run.lineHeight);
|
|
1832
2471
|
applyFirstLineShift(root, shift);
|
|
1833
2472
|
};
|
|
@@ -1857,28 +2496,28 @@ function attachRichTextEditor(el, host, initial) {
|
|
|
1857
2496
|
composing = false;
|
|
1858
2497
|
serialiseAndReport();
|
|
1859
2498
|
};
|
|
1860
|
-
const onKeyDown = (
|
|
1861
|
-
if (!(
|
|
1862
|
-
const key =
|
|
2499
|
+
const onKeyDown = (event) => {
|
|
2500
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
|
|
2501
|
+
const key = event.key.toLowerCase();
|
|
1863
2502
|
const command = key === "b" ? "bold" : key === "i" ? "italic" : key === "u" ? "underline" : null;
|
|
1864
2503
|
if (!command) return;
|
|
1865
|
-
|
|
2504
|
+
event.preventDefault();
|
|
1866
2505
|
host.onCommand(command);
|
|
1867
2506
|
};
|
|
1868
|
-
const onPaste = (
|
|
1869
|
-
|
|
1870
|
-
const text =
|
|
2507
|
+
const onPaste = (event) => {
|
|
2508
|
+
event.preventDefault();
|
|
2509
|
+
const text = event.clipboardData?.getData("text/plain") ?? "";
|
|
1871
2510
|
doc.execCommand("insertText", false, text);
|
|
1872
2511
|
};
|
|
1873
2512
|
const onSelectionChange = () => {
|
|
1874
|
-
if (activeElement() !==
|
|
2513
|
+
if (activeElement() !== element) return;
|
|
1875
2514
|
host.onSelectionChange(currentSelection());
|
|
1876
2515
|
};
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2516
|
+
element.addEventListener("input", onInput);
|
|
2517
|
+
element.addEventListener("compositionstart", onCompositionStart);
|
|
2518
|
+
element.addEventListener("compositionend", onCompositionEnd);
|
|
2519
|
+
element.addEventListener("keydown", onKeyDown);
|
|
2520
|
+
element.addEventListener("paste", onPaste);
|
|
1882
2521
|
doc.addEventListener("selectionchange", onSelectionChange);
|
|
1883
2522
|
render(initial.document, false);
|
|
1884
2523
|
const stopObservingFonts = observeWebFonts(doc, refreshLineModel);
|
|
@@ -1888,7 +2527,7 @@ function attachRichTextEditor(el, host, initial) {
|
|
|
1888
2527
|
props = next;
|
|
1889
2528
|
if (composing) return;
|
|
1890
2529
|
if (!rendered || scaleChanged || !documentsEqual(next.document, rendered)) {
|
|
1891
|
-
render(next.document, activeElement() ===
|
|
2530
|
+
render(next.document, activeElement() === element);
|
|
1892
2531
|
return;
|
|
1893
2532
|
}
|
|
1894
2533
|
refreshLineModel();
|
|
@@ -1897,16 +2536,2565 @@ function attachRichTextEditor(el, host, initial) {
|
|
|
1897
2536
|
select,
|
|
1898
2537
|
detach() {
|
|
1899
2538
|
stopObservingFonts();
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2539
|
+
element.removeEventListener("input", onInput);
|
|
2540
|
+
element.removeEventListener("compositionstart", onCompositionStart);
|
|
2541
|
+
element.removeEventListener("compositionend", onCompositionEnd);
|
|
2542
|
+
element.removeEventListener("keydown", onKeyDown);
|
|
2543
|
+
element.removeEventListener("paste", onPaste);
|
|
1905
2544
|
doc.removeEventListener("selectionchange", onSelectionChange);
|
|
1906
2545
|
}
|
|
1907
2546
|
};
|
|
1908
2547
|
}
|
|
1909
2548
|
//#endregion
|
|
1910
|
-
|
|
2549
|
+
//#region src/picture-or-painting.ts
|
|
2550
|
+
function pictureOrPainting(look) {
|
|
2551
|
+
if (!look.pending && !look.loaded) return null;
|
|
2552
|
+
if (look.picture && look.picture.version === look.version && !look.pending) return {
|
|
2553
|
+
kind: "picture",
|
|
2554
|
+
picture: look.picture
|
|
2555
|
+
};
|
|
2556
|
+
const painting = look.paint();
|
|
2557
|
+
if (painting) return {
|
|
2558
|
+
kind: "painting",
|
|
2559
|
+
painting
|
|
2560
|
+
};
|
|
2561
|
+
return look.picture ? {
|
|
2562
|
+
kind: "picture",
|
|
2563
|
+
picture: look.picture
|
|
2564
|
+
} : null;
|
|
2565
|
+
}
|
|
2566
|
+
//#endregion
|
|
2567
|
+
//#region src/theme.ts
|
|
2568
|
+
/**
|
|
2569
|
+
* A dash variable holds an SVG dash array, while its setting says `'solid'` or
|
|
2570
|
+
* `'dashed'`: solid has no dashes, and dashed is 4 pixels on, 3 off.
|
|
2571
|
+
*/
|
|
2572
|
+
const DASH_KEYWORDS = {
|
|
2573
|
+
solid: "none",
|
|
2574
|
+
dashed: "4 3"
|
|
2575
|
+
};
|
|
2576
|
+
/** Every theming variable, in the order the theming page lists them. */
|
|
2577
|
+
const EPDF_VARIABLES = {
|
|
2578
|
+
accent: {
|
|
2579
|
+
setting: "viewer.accent",
|
|
2580
|
+
default: "#3858e9"
|
|
2581
|
+
},
|
|
2582
|
+
"page-background": {
|
|
2583
|
+
setting: "viewer.page.background",
|
|
2584
|
+
default: "#ffffff"
|
|
2585
|
+
},
|
|
2586
|
+
"page-shadow": {
|
|
2587
|
+
setting: "viewer.page.shadow",
|
|
2588
|
+
default: "0 6px 18px rgb(0 0 0 / 0.18)"
|
|
2589
|
+
},
|
|
2590
|
+
"annotation-accent": {
|
|
2591
|
+
setting: "annotation.chrome.accent",
|
|
2592
|
+
follows: "accent",
|
|
2593
|
+
default: "#3858e9"
|
|
2594
|
+
},
|
|
2595
|
+
"annotation-outline": {
|
|
2596
|
+
setting: "annotation.chrome.outline.color",
|
|
2597
|
+
follows: "annotation-accent",
|
|
2598
|
+
default: "#3858e9"
|
|
2599
|
+
},
|
|
2600
|
+
"annotation-outline-width": {
|
|
2601
|
+
setting: "annotation.chrome.outline.width",
|
|
2602
|
+
default: "1"
|
|
2603
|
+
},
|
|
2604
|
+
"annotation-outline-dash": {
|
|
2605
|
+
setting: "annotation.chrome.outline.style",
|
|
2606
|
+
keywords: DASH_KEYWORDS,
|
|
2607
|
+
default: "none"
|
|
2608
|
+
},
|
|
2609
|
+
"annotation-handle-fill": {
|
|
2610
|
+
setting: "annotation.chrome.handles.fill",
|
|
2611
|
+
default: "#ffffff"
|
|
2612
|
+
},
|
|
2613
|
+
"annotation-handle-stroke": {
|
|
2614
|
+
setting: "annotation.chrome.handles.stroke",
|
|
2615
|
+
follows: "annotation-accent",
|
|
2616
|
+
default: "#3858e9"
|
|
2617
|
+
},
|
|
2618
|
+
"annotation-rotation-handle-fill": {
|
|
2619
|
+
setting: "annotation.chrome.rotationHandle.fill",
|
|
2620
|
+
follows: "annotation-handle-fill",
|
|
2621
|
+
default: "#ffffff"
|
|
2622
|
+
},
|
|
2623
|
+
"annotation-rotation-handle-stroke": {
|
|
2624
|
+
setting: "annotation.chrome.rotationHandle.stroke",
|
|
2625
|
+
follows: "annotation-handle-stroke",
|
|
2626
|
+
default: "#3858e9"
|
|
2627
|
+
},
|
|
2628
|
+
"annotation-guide": {
|
|
2629
|
+
setting: "annotation.chrome.guides.color",
|
|
2630
|
+
default: "#e91e63"
|
|
2631
|
+
},
|
|
2632
|
+
"annotation-rotation-guide": {
|
|
2633
|
+
setting: "annotation.chrome.guides.rotationColor",
|
|
2634
|
+
follows: "annotation-accent",
|
|
2635
|
+
default: "#3858e9"
|
|
2636
|
+
},
|
|
2637
|
+
"annotation-guide-width": {
|
|
2638
|
+
setting: "annotation.chrome.guides.width",
|
|
2639
|
+
default: "1.5"
|
|
2640
|
+
},
|
|
2641
|
+
"annotation-guide-dash": {
|
|
2642
|
+
setting: "annotation.chrome.guides.style",
|
|
2643
|
+
keywords: DASH_KEYWORDS,
|
|
2644
|
+
default: "none"
|
|
2645
|
+
},
|
|
2646
|
+
"annotation-marquee-fill": {
|
|
2647
|
+
setting: "annotation.chrome.marquee.fill",
|
|
2648
|
+
follows: "annotation-accent",
|
|
2649
|
+
accentPercent: 8,
|
|
2650
|
+
default: "color-mix(in srgb, #3858e9 8%, transparent)"
|
|
2651
|
+
},
|
|
2652
|
+
"annotation-marquee-stroke": {
|
|
2653
|
+
setting: "annotation.chrome.marquee.stroke",
|
|
2654
|
+
follows: "annotation-accent",
|
|
2655
|
+
default: "#3858e9"
|
|
2656
|
+
},
|
|
2657
|
+
"annotation-text-outline": {
|
|
2658
|
+
setting: "annotation.chrome.textOutline",
|
|
2659
|
+
follows: "annotation-accent",
|
|
2660
|
+
default: "#3858e9"
|
|
2661
|
+
},
|
|
2662
|
+
"readout-background": {
|
|
2663
|
+
setting: "annotation.chrome.readout.background",
|
|
2664
|
+
default: "rgb(0 0 0 / 0.8)"
|
|
2665
|
+
},
|
|
2666
|
+
"readout-color": {
|
|
2667
|
+
setting: "annotation.chrome.readout.color",
|
|
2668
|
+
default: "#ffffff"
|
|
2669
|
+
},
|
|
2670
|
+
"ghost-opacity": {
|
|
2671
|
+
setting: "annotation.tools[].ghost.opacity",
|
|
2672
|
+
default: "0.5"
|
|
2673
|
+
},
|
|
2674
|
+
"text-selection": {
|
|
2675
|
+
setting: "selection.color",
|
|
2676
|
+
follows: "accent",
|
|
2677
|
+
accentPercent: 35,
|
|
2678
|
+
default: "color-mix(in srgb, #3858e9 35%, transparent)"
|
|
2679
|
+
},
|
|
2680
|
+
"text-selection-handle": {
|
|
2681
|
+
setting: "selection.handles.color",
|
|
2682
|
+
follows: "accent",
|
|
2683
|
+
default: "#3858e9"
|
|
2684
|
+
},
|
|
2685
|
+
"text-selection-handle-shadow": {
|
|
2686
|
+
setting: "selection.handles.shadow",
|
|
2687
|
+
default: "0 1px 4px rgb(0 0 0 / 0.35)"
|
|
2688
|
+
},
|
|
2689
|
+
"search-highlight": {
|
|
2690
|
+
setting: "search.highlight.color",
|
|
2691
|
+
default: "#ffd500"
|
|
2692
|
+
},
|
|
2693
|
+
"search-highlight-active": {
|
|
2694
|
+
setting: "search.highlight.activeColor",
|
|
2695
|
+
default: "#ff9632"
|
|
2696
|
+
},
|
|
2697
|
+
"search-blend-mode": {
|
|
2698
|
+
setting: "search.highlight.blendMode",
|
|
2699
|
+
default: "multiply"
|
|
2700
|
+
},
|
|
2701
|
+
"form-focus": {
|
|
2702
|
+
setting: "form.focus.color",
|
|
2703
|
+
follows: "accent",
|
|
2704
|
+
default: "#3858e9"
|
|
2705
|
+
},
|
|
2706
|
+
"form-field-border": {
|
|
2707
|
+
setting: "form.fields.border",
|
|
2708
|
+
follows: "accent",
|
|
2709
|
+
accentPercent: 55,
|
|
2710
|
+
default: "color-mix(in srgb, #3858e9 55%, transparent)"
|
|
2711
|
+
},
|
|
2712
|
+
"form-field-background": {
|
|
2713
|
+
setting: "form.fields.background",
|
|
2714
|
+
default: "rgb(255 255 255 / 0.92)"
|
|
2715
|
+
},
|
|
2716
|
+
"form-field-color": {
|
|
2717
|
+
setting: "form.fields.color",
|
|
2718
|
+
default: "#1f2a44"
|
|
2719
|
+
},
|
|
2720
|
+
"scrollbar-thumb": { default: "rgb(0 0 0 / 0.4)" },
|
|
2721
|
+
"toolbar-surface": { default: "#ffffff" },
|
|
2722
|
+
"toolbar-border": { default: "rgb(0 0 0 / 0.15)" },
|
|
2723
|
+
"toolbar-active": { default: "rgb(0 0 0 / 0.12)" },
|
|
2724
|
+
"font-mono": { default: "ui-monospace, monospace" }
|
|
2725
|
+
};
|
|
2726
|
+
/**
|
|
2727
|
+
* The CSS value for a painted part: its own variable, then the variables of the
|
|
2728
|
+
* parts its default follows (the part it follows, its plugin's accent,
|
|
2729
|
+
* `--epdf-accent`), then the setting's value. Put it in a style (`style.stroke`),
|
|
2730
|
+
* not an SVG attribute, because attributes don't read `var()`.
|
|
2731
|
+
*
|
|
2732
|
+
* `paint('annotation-outline', '#3858e9')` is
|
|
2733
|
+
* `var(--epdf-annotation-outline, var(--epdf-annotation-accent, var(--epdf-accent, #3858e9)))`,
|
|
2734
|
+
* `paint('annotation-rotation-handle-fill', '#ffffff')` is
|
|
2735
|
+
* `var(--epdf-annotation-rotation-handle-fill, var(--epdf-annotation-handle-fill, #ffffff))`,
|
|
2736
|
+
* and `paint('search-highlight', '#ffd500')` is `var(--epdf-search-highlight, #ffd500)`.
|
|
2737
|
+
*/
|
|
2738
|
+
function paint(name, settingValue) {
|
|
2739
|
+
const definition = EPDF_VARIABLES[name];
|
|
2740
|
+
const value = String(settingValue);
|
|
2741
|
+
const css = definition.keywords?.[value] ?? value;
|
|
2742
|
+
const followed = definition.accentPercent === void 0 ? definition.follows : void 0;
|
|
2743
|
+
return `var(--epdf-${name}, ${followed ? paint(followed, css) : css})`;
|
|
2744
|
+
}
|
|
2745
|
+
/**
|
|
2746
|
+
* The default of a translucent part, such as "the accent at 35%": the accent it
|
|
2747
|
+
* follows, variables included, mixed with transparent. Use it as the setting's
|
|
2748
|
+
* value while the part's own setting is unset.
|
|
2749
|
+
*
|
|
2750
|
+
* The accent variables sit inside the mix, not in {@link paint}'s lookup: there
|
|
2751
|
+
* a set `--epdf-accent` would paint at full strength, and CSS can't mix a
|
|
2752
|
+
* variable only when it's set. So a part's own setting, when you give one, wins
|
|
2753
|
+
* over the accent variables, though not over the part's own variable.
|
|
2754
|
+
*
|
|
2755
|
+
* `mixAccent('text-selection', '#3858e9')` is
|
|
2756
|
+
* `color-mix(in srgb, var(--epdf-accent, #3858e9) 35%, transparent)`.
|
|
2757
|
+
*/
|
|
2758
|
+
function mixAccent(name, accentValue) {
|
|
2759
|
+
const { follows, accentPercent } = EPDF_VARIABLES[name];
|
|
2760
|
+
return `color-mix(in srgb, ${paint(follows, accentValue)} ${accentPercent}%, transparent)`;
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* The CSS value for a part you style yourself, which has no setting: its
|
|
2764
|
+
* variable, then its built-in look.
|
|
2765
|
+
*
|
|
2766
|
+
* `paintDefault('scrollbar-thumb')` is `var(--epdf-scrollbar-thumb, rgb(0 0 0 / 0.4))`.
|
|
2767
|
+
*/
|
|
2768
|
+
function paintDefault(name) {
|
|
2769
|
+
return `var(--epdf-${name}, ${EPDF_VARIABLES[name].default})`;
|
|
2770
|
+
}
|
|
2771
|
+
/** The value at a dotted path of a theme, or `undefined`. */
|
|
2772
|
+
function valueAt(theme, path) {
|
|
2773
|
+
let node = theme;
|
|
2774
|
+
for (const key of path) {
|
|
2775
|
+
if (typeof node !== "object" || node === null) return void 0;
|
|
2776
|
+
node = node[key];
|
|
2777
|
+
}
|
|
2778
|
+
return node;
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* The CSS variables that set a theme, for a style on an element around the viewer: CSS variables
|
|
2782
|
+
* win over settings, and reach everything inside the element. A part left out keeps its own
|
|
2783
|
+
* look, so the parts that follow the accent follow a new `accent`.
|
|
2784
|
+
*
|
|
2785
|
+
* `epdfThemeVariables({ accent: '#e91e63', page: { shadow: 'none' } })` is
|
|
2786
|
+
* `{ '--epdf-accent': '#e91e63', '--epdf-page-shadow': 'none' }`.
|
|
2787
|
+
*/
|
|
2788
|
+
function epdfThemeVariables(theme) {
|
|
2789
|
+
const variables = {};
|
|
2790
|
+
for (const [name, definition] of Object.entries(EPDF_VARIABLES)) {
|
|
2791
|
+
if (!definition.setting) continue;
|
|
2792
|
+
const value = valueAt(theme, definition.setting.replace(/^viewer\./, "").replace("[]", "").split("."));
|
|
2793
|
+
if (value === void 0 || value === null) continue;
|
|
2794
|
+
const css = String(value);
|
|
2795
|
+
variables[`--epdf-${name}`] = definition.keywords?.[css] ?? css;
|
|
2796
|
+
}
|
|
2797
|
+
return variables;
|
|
2798
|
+
}
|
|
2799
|
+
//#endregion
|
|
2800
|
+
//#region src/page-pixels.ts
|
|
2801
|
+
/** A box in page points as a box in the page layer's pixels. */
|
|
2802
|
+
function rectInPixels(rect, page) {
|
|
2803
|
+
const topLeft = page.toPixels({
|
|
2804
|
+
x: rect.x,
|
|
2805
|
+
y: rect.y
|
|
2806
|
+
});
|
|
2807
|
+
const bottomRight = page.toPixels({
|
|
2808
|
+
x: rect.x + rect.width,
|
|
2809
|
+
y: rect.y + rect.height
|
|
2810
|
+
});
|
|
2811
|
+
return {
|
|
2812
|
+
left: topLeft.x,
|
|
2813
|
+
top: topLeft.y,
|
|
2814
|
+
width: bottomRight.x - topLeft.x,
|
|
2815
|
+
height: bottomRight.y - topLeft.y
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* A quad's corners in the page layer's pixels, in drawing order (upper left,
|
|
2820
|
+
* upper right, lower right, lower left). The map is affine, so mapping the
|
|
2821
|
+
* corners is exact, for turned text too.
|
|
2822
|
+
*/
|
|
2823
|
+
function quadInPixels(quad, page) {
|
|
2824
|
+
return [
|
|
2825
|
+
quad.upperLeft,
|
|
2826
|
+
quad.upperRight,
|
|
2827
|
+
quad.lowerRight,
|
|
2828
|
+
quad.lowerLeft
|
|
2829
|
+
].map((point) => page.toPixels(point));
|
|
2830
|
+
}
|
|
2831
|
+
/** Points as an SVG `points` attribute: `"x,y x,y …"`. */
|
|
2832
|
+
function svgPoints(points) {
|
|
2833
|
+
return points.map((point) => `${point.x},${point.y}`).join(" ");
|
|
2834
|
+
}
|
|
2835
|
+
/** Whether a quad is an upright box: drawn as a positioned box instead of a polygon. */
|
|
2836
|
+
function isUprightQuad(quad) {
|
|
2837
|
+
return quad.upperLeft.y === quad.upperRight.y && quad.lowerLeft.y === quad.lowerRight.y && quad.upperLeft.x === quad.lowerLeft.x;
|
|
2838
|
+
}
|
|
2839
|
+
//#endregion
|
|
2840
|
+
//#region src/event-isolation.ts
|
|
2841
|
+
/**
|
|
2842
|
+
* Keeping a page's own listeners out of controls drawn over it. The Stage
|
|
2843
|
+
* listens natively on an ancestor of every page: a press that bubbles to it
|
|
2844
|
+
* starts a tool gesture or reads as a click outside (ending an edit), and a
|
|
2845
|
+
* wheel pans or zooms the page. A control that owns its gestures stops them
|
|
2846
|
+
* with a native listener on itself, which runs during real DOM bubbling,
|
|
2847
|
+
* before the Stage's. A framework's own event system may run too late: React
|
|
2848
|
+
* dispatches from its root, above the Stage.
|
|
2849
|
+
*/
|
|
2850
|
+
/**
|
|
2851
|
+
* Stop presses that begin inside `element` from reaching the page, and tell `onPress` about each
|
|
2852
|
+
* one. A control that needs the press itself (a link or a form field sending its PDF "mouse down"
|
|
2853
|
+
* action) must take it here: once the press stops at the element, a framework that dispatches
|
|
2854
|
+
* from its root (React) never sees it. Returns the detach.
|
|
2855
|
+
*/
|
|
2856
|
+
function isolatePointerDown(element, onPress) {
|
|
2857
|
+
const stop = (event) => {
|
|
2858
|
+
event.stopPropagation();
|
|
2859
|
+
onPress?.(event);
|
|
2860
|
+
};
|
|
2861
|
+
element.addEventListener("pointerdown", stop);
|
|
2862
|
+
return () => element.removeEventListener("pointerdown", stop);
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Stop wheel events inside `element` from reaching the page, so the browser
|
|
2866
|
+
* scrolls the element itself (a list box) instead of the Stage panning or
|
|
2867
|
+
* zooming. Returns the detach.
|
|
2868
|
+
*/
|
|
2869
|
+
function isolateWheel(element) {
|
|
2870
|
+
const stop = (event) => event.stopPropagation();
|
|
2871
|
+
element.addEventListener("wheel", stop);
|
|
2872
|
+
return () => element.removeEventListener("wheel", stop);
|
|
2873
|
+
}
|
|
2874
|
+
//#endregion
|
|
2875
|
+
//#region src/outside-press.ts
|
|
2876
|
+
/**
|
|
2877
|
+
* Call `onPress` when a pointer press starts anywhere in `element`'s document
|
|
2878
|
+
* outside `element`: how a menu closes when the user presses somewhere else,
|
|
2879
|
+
* in the viewer or on the page around it. Where the press landed is read from
|
|
2880
|
+
* its composed path: at the document, an event from inside a shadow root is
|
|
2881
|
+
* retargeted to the shadow host, so `element.contains(event.target)` would
|
|
2882
|
+
* call every press inside a shadow-rooted menu an outside one. Listens in the
|
|
2883
|
+
* capture phase, so a handler that stops the press can't keep a menu open.
|
|
2884
|
+
* Returns the stop.
|
|
2885
|
+
*/
|
|
2886
|
+
function observeOutsidePress(element, onPress) {
|
|
2887
|
+
const doc = element.ownerDocument;
|
|
2888
|
+
const listener = (event) => {
|
|
2889
|
+
if (!event.composedPath().includes(element)) onPress(event);
|
|
2890
|
+
};
|
|
2891
|
+
doc.addEventListener("pointerdown", listener, true);
|
|
2892
|
+
return () => doc.removeEventListener("pointerdown", listener, true);
|
|
2893
|
+
}
|
|
2894
|
+
//#endregion
|
|
2895
|
+
//#region src/anchor-equality.ts
|
|
2896
|
+
const sameBox = (left, right) => left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
|
|
2897
|
+
/** The same page and box (a text selection's menu anchor). */
|
|
2898
|
+
function samePageBounds(left, right) {
|
|
2899
|
+
if (left === right) return true;
|
|
2900
|
+
if (!left || !right) return false;
|
|
2901
|
+
return left.page.objectNumber === right.page.objectNumber && sameBox(left.bounds, right.bounds);
|
|
2902
|
+
}
|
|
2903
|
+
/** The same annotation selection anchor: page, box and rotation handle. */
|
|
2904
|
+
function sameSelectionAnchor(left, right) {
|
|
2905
|
+
if (left === right) return true;
|
|
2906
|
+
if (!left || !right) return false;
|
|
2907
|
+
return samePageBounds(left, right) && left.rotationHandle?.x === right.rotationHandle?.x && left.rotationHandle?.y === right.rotationHandle?.y;
|
|
2908
|
+
}
|
|
2909
|
+
/** The same annotation anchor: page, box, and how it follows the view. */
|
|
2910
|
+
function sameAnnotationAnchor(left, right) {
|
|
2911
|
+
if (left === right) return true;
|
|
2912
|
+
if (!left || !right) return false;
|
|
2913
|
+
return samePageBounds(left, right) && left.boundsIn === right.boundsIn;
|
|
2914
|
+
}
|
|
2915
|
+
/** The same creation draft: what it draws, its points so far, whether it can finish, and its box. */
|
|
2916
|
+
function sameCreationDraftAnchor(left, right) {
|
|
2917
|
+
if (left === right) return true;
|
|
2918
|
+
if (!left || !right) return false;
|
|
2919
|
+
return left.kind === right.kind && left.subtype === right.subtype && left.pointCount === right.pointCount && left.minPoints === right.minPoints && left.canFinish === right.canFinish && samePageBounds(left, right);
|
|
2920
|
+
}
|
|
2921
|
+
/** The same turn in progress: page, pointer and angle. */
|
|
2922
|
+
function sameRotationAnchor(left, right) {
|
|
2923
|
+
if (left === right) return true;
|
|
2924
|
+
if (!left || !right) return false;
|
|
2925
|
+
return left.page.objectNumber === right.page.objectNumber && left.at.x === right.at.x && left.at.y === right.at.y && left.angle === right.angle;
|
|
2926
|
+
}
|
|
2927
|
+
const samePoint = (left, right) => left.x === right.x && left.y === right.y;
|
|
2928
|
+
const sameQuad = (left, right) => samePoint(left.upperLeft, right.upperLeft) && samePoint(left.upperRight, right.upperRight) && samePoint(left.lowerLeft, right.lowerLeft) && samePoint(left.lowerRight, right.lowerRight);
|
|
2929
|
+
/**
|
|
2930
|
+
* The same selection ends. The glyph quads compare corner by corner, so an
|
|
2931
|
+
* end that turns without moving its bounding box still counts as moved.
|
|
2932
|
+
*/
|
|
2933
|
+
function sameSelectionEndpoints(left, right) {
|
|
2934
|
+
if (left === right) return true;
|
|
2935
|
+
if (!left || !right) return false;
|
|
2936
|
+
return left.start.page.objectNumber === right.start.page.objectNumber && left.end.page.objectNumber === right.end.page.objectNumber && left.start.advance === right.start.advance && left.end.advance === right.end.advance && sameQuad(left.start.glyphQuad, right.start.glyphQuad) && sameQuad(left.end.glyphQuad, right.end.glyphQuad);
|
|
2937
|
+
}
|
|
2938
|
+
//#endregion
|
|
2939
|
+
//#region src/annotation-scene.ts
|
|
2940
|
+
/**
|
|
2941
|
+
* An annotation's painted scene as SVG elements. The annotation core turns a
|
|
2942
|
+
* render item into its scene (`scene(item)` in `@embedpdf/core-annotation`):
|
|
2943
|
+
* rects, ellipses, lines, polygons, paths and text, each with its paint.
|
|
2944
|
+
* {@link svgShapesOf} turns that scene into one SVG element description per
|
|
2945
|
+
* node (its tag, its attributes under their SVG names, and its text), so a
|
|
2946
|
+
* framework adapter draws it with one loop and no per-kind logic. The scene's
|
|
2947
|
+
* types are mirrored structurally, so this package stays free of EmbedPDF
|
|
2948
|
+
* imports.
|
|
2949
|
+
*/
|
|
2950
|
+
/** The faces shapes draw their text in, each once: a renderer mounts them while it shows the shapes. */
|
|
2951
|
+
function fontProgramsOf(shapes) {
|
|
2952
|
+
const programs = /* @__PURE__ */ new Map();
|
|
2953
|
+
const walk = (each) => {
|
|
2954
|
+
for (const shape of each) {
|
|
2955
|
+
if (shape.program) programs.set(shape.program.family, shape.program);
|
|
2956
|
+
if (shape.children) walk(shape.children);
|
|
2957
|
+
}
|
|
2958
|
+
};
|
|
2959
|
+
walk(shapes);
|
|
2960
|
+
return [...programs.values()];
|
|
2961
|
+
}
|
|
2962
|
+
/** A node's paint as SVG presentation attributes; everything else about its look is the core's. */
|
|
2963
|
+
function paintAttributes(nodePaint, miterLimit) {
|
|
2964
|
+
const attributes = {
|
|
2965
|
+
fill: nodePaint.fill ?? "none",
|
|
2966
|
+
stroke: nodePaint.stroke ?? "none",
|
|
2967
|
+
"stroke-linejoin": nodePaint.join ?? "miter",
|
|
2968
|
+
"stroke-miterlimit": miterLimit
|
|
2969
|
+
};
|
|
2970
|
+
if (nodePaint.fillRule !== void 0) attributes["fill-rule"] = nodePaint.fillRule;
|
|
2971
|
+
if (nodePaint.width !== void 0) attributes["stroke-width"] = nodePaint.width;
|
|
2972
|
+
if (nodePaint.opacity !== void 0) attributes.opacity = nodePaint.opacity;
|
|
2973
|
+
if (nodePaint.lineCap !== void 0) attributes["stroke-linecap"] = nodePaint.lineCap;
|
|
2974
|
+
if (nodePaint.dash) attributes["stroke-dasharray"] = nodePaint.dash.join(" ");
|
|
2975
|
+
return attributes;
|
|
2976
|
+
}
|
|
2977
|
+
/**
|
|
2978
|
+
* Each scene node as one SVG element, in drawing order, in the scene's own
|
|
2979
|
+
* page-point units. A clipped node comes inside an `svg` of its clip box,
|
|
2980
|
+
* which shows only what's inside it (no ids, so every framework draws it the
|
|
2981
|
+
* same).
|
|
2982
|
+
*/
|
|
2983
|
+
function svgShapesOf(nodes, options) {
|
|
2984
|
+
return nodes.map((node) => {
|
|
2985
|
+
const shape = svgShapeOf(node, options);
|
|
2986
|
+
if (!node.clip) return shape;
|
|
2987
|
+
const { x, y, width, height } = node.clip;
|
|
2988
|
+
return {
|
|
2989
|
+
tag: "svg",
|
|
2990
|
+
attributes: {
|
|
2991
|
+
x,
|
|
2992
|
+
y,
|
|
2993
|
+
width,
|
|
2994
|
+
height,
|
|
2995
|
+
viewBox: `${x} ${y} ${width} ${height}`,
|
|
2996
|
+
overflow: "hidden"
|
|
2997
|
+
},
|
|
2998
|
+
children: [shape]
|
|
2999
|
+
};
|
|
3000
|
+
});
|
|
3001
|
+
}
|
|
3002
|
+
function svgShapeOf(node, options) {
|
|
3003
|
+
const painted = paintAttributes(node.paint, options.miterLimit);
|
|
3004
|
+
const blend = node.paint.blend ? { blend: node.paint.blend } : {};
|
|
3005
|
+
switch (node.kind) {
|
|
3006
|
+
case "rect": return {
|
|
3007
|
+
tag: "rect",
|
|
3008
|
+
attributes: {
|
|
3009
|
+
x: node.rect.x,
|
|
3010
|
+
y: node.rect.y,
|
|
3011
|
+
width: node.rect.width,
|
|
3012
|
+
height: node.rect.height,
|
|
3013
|
+
...painted
|
|
3014
|
+
},
|
|
3015
|
+
...blend
|
|
3016
|
+
};
|
|
3017
|
+
case "ellipse": return {
|
|
3018
|
+
tag: "ellipse",
|
|
3019
|
+
attributes: {
|
|
3020
|
+
cx: node.rect.x + node.rect.width / 2,
|
|
3021
|
+
cy: node.rect.y + node.rect.height / 2,
|
|
3022
|
+
rx: node.rect.width / 2,
|
|
3023
|
+
ry: node.rect.height / 2,
|
|
3024
|
+
...painted
|
|
3025
|
+
},
|
|
3026
|
+
...blend
|
|
3027
|
+
};
|
|
3028
|
+
case "line": return {
|
|
3029
|
+
tag: "line",
|
|
3030
|
+
attributes: {
|
|
3031
|
+
x1: node.a.x,
|
|
3032
|
+
y1: node.a.y,
|
|
3033
|
+
x2: node.b.x,
|
|
3034
|
+
y2: node.b.y,
|
|
3035
|
+
...painted
|
|
3036
|
+
},
|
|
3037
|
+
...blend
|
|
3038
|
+
};
|
|
3039
|
+
case "path": return {
|
|
3040
|
+
tag: "path",
|
|
3041
|
+
attributes: {
|
|
3042
|
+
d: node.d,
|
|
3043
|
+
...painted
|
|
3044
|
+
},
|
|
3045
|
+
...blend
|
|
3046
|
+
};
|
|
3047
|
+
case "text": return {
|
|
3048
|
+
tag: "text",
|
|
3049
|
+
attributes: {
|
|
3050
|
+
x: node.at.x,
|
|
3051
|
+
y: node.at.y,
|
|
3052
|
+
...node.rotation ? { transform: `rotate(${node.rotation} ${node.at.x} ${node.at.y})` } : {},
|
|
3053
|
+
"font-size": node.fontSize,
|
|
3054
|
+
...node.fontFamily ? { "font-family": node.fontFamily } : {},
|
|
3055
|
+
...painted
|
|
3056
|
+
},
|
|
3057
|
+
text: node.text,
|
|
3058
|
+
...blend
|
|
3059
|
+
};
|
|
3060
|
+
case "poly": return {
|
|
3061
|
+
tag: node.closed ? "polygon" : "polyline",
|
|
3062
|
+
attributes: {
|
|
3063
|
+
points: node.points.map((point) => `${point.x},${point.y}`).join(" "),
|
|
3064
|
+
...painted
|
|
3065
|
+
},
|
|
3066
|
+
...blend
|
|
3067
|
+
};
|
|
3068
|
+
case "glyphs": {
|
|
3069
|
+
const drawn = node.text.split("").flatMap((character, index) => /\s/.test(character) ? [] : [{
|
|
3070
|
+
character,
|
|
3071
|
+
x: node.at.x + (node.offsets[index] ?? 0)
|
|
3072
|
+
}]);
|
|
3073
|
+
return {
|
|
3074
|
+
tag: "text",
|
|
3075
|
+
attributes: {
|
|
3076
|
+
x: drawn.map((glyph) => glyph.x).join(" "),
|
|
3077
|
+
y: node.at.y,
|
|
3078
|
+
...node.rotation ? { transform: `rotate(${node.rotation} ${node.at.x} ${node.at.y})` } : {},
|
|
3079
|
+
"font-size": node.fontSize,
|
|
3080
|
+
"font-family": node.fontFamily,
|
|
3081
|
+
...node.fontWeight !== void 0 ? { "font-weight": node.fontWeight } : {},
|
|
3082
|
+
...node.fontStyle !== void 0 ? { "font-style": node.fontStyle } : {},
|
|
3083
|
+
...painted
|
|
3084
|
+
},
|
|
3085
|
+
text: drawn.map((glyph) => glyph.character).join(""),
|
|
3086
|
+
...node.program ? { program: node.program } : {},
|
|
3087
|
+
...blend
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
/**
|
|
3093
|
+
* The `viewBox` an item's scene draws in: its box, in page points. `null`
|
|
3094
|
+
* while the box has no area (a drawing at its first press): draw nothing then.
|
|
3095
|
+
*
|
|
3096
|
+
* The `<svg>` fills the item's frame, so the viewBox and the element's size
|
|
3097
|
+
* stay proportional (their ratio is the zoom). Never clamp either one: a
|
|
3098
|
+
* clamped element with a shrinking viewBox scales its content up, and a
|
|
3099
|
+
* cloudy border's scallops flood the page.
|
|
3100
|
+
*/
|
|
3101
|
+
function sceneViewBox(box) {
|
|
3102
|
+
if (box.width <= 0 || box.height <= 0) return null;
|
|
3103
|
+
return `${box.x} ${box.y} ${box.width} ${box.height}`;
|
|
3104
|
+
}
|
|
3105
|
+
/** How opaque a tool's ghost paints: `--epdf-ghost-opacity` wins over the tool's own. */
|
|
3106
|
+
function ghostOpacity(opacity) {
|
|
3107
|
+
return paint("ghost-opacity", opacity);
|
|
3108
|
+
}
|
|
3109
|
+
//#endregion
|
|
3110
|
+
//#region src/annotation-chrome.ts
|
|
3111
|
+
/**
|
|
3112
|
+
* The chrome settings as paint. A color left `null` follows the part it
|
|
3113
|
+
* names, and in the end the accent: the chrome's own, else the viewer's.
|
|
3114
|
+
*/
|
|
3115
|
+
function annotationChromePaint(chrome, viewerAccent) {
|
|
3116
|
+
const accent = chrome.accent ?? viewerAccent;
|
|
3117
|
+
const guideLine = {
|
|
3118
|
+
strokeWidth: paint("annotation-guide-width", chrome.guides.width),
|
|
3119
|
+
strokeDasharray: paint("annotation-guide-dash", chrome.guides.style)
|
|
3120
|
+
};
|
|
3121
|
+
return {
|
|
3122
|
+
outline: {
|
|
3123
|
+
stroke: paint("annotation-outline", chrome.outline.color ?? accent),
|
|
3124
|
+
strokeWidth: paint("annotation-outline-width", chrome.outline.width),
|
|
3125
|
+
strokeDasharray: paint("annotation-outline-dash", chrome.outline.style)
|
|
3126
|
+
},
|
|
3127
|
+
handle: {
|
|
3128
|
+
fill: paint("annotation-handle-fill", chrome.handles.fill),
|
|
3129
|
+
stroke: paint("annotation-handle-stroke", chrome.handles.stroke ?? accent)
|
|
3130
|
+
},
|
|
3131
|
+
rotationHandle: {
|
|
3132
|
+
fill: paint("annotation-rotation-handle-fill", chrome.rotationHandle.fill ?? chrome.handles.fill),
|
|
3133
|
+
stroke: paint("annotation-rotation-handle-stroke", chrome.rotationHandle.stroke ?? chrome.handles.stroke ?? accent)
|
|
3134
|
+
},
|
|
3135
|
+
guide: {
|
|
3136
|
+
stroke: paint("annotation-guide", chrome.guides.color),
|
|
3137
|
+
...guideLine
|
|
3138
|
+
},
|
|
3139
|
+
rotationGuide: {
|
|
3140
|
+
stroke: paint("annotation-rotation-guide", chrome.guides.rotationColor ?? accent),
|
|
3141
|
+
...guideLine
|
|
3142
|
+
},
|
|
3143
|
+
marquee: {
|
|
3144
|
+
fill: paint("annotation-marquee-fill", chrome.marquee.fill ?? mixAccent("annotation-marquee-fill", accent)),
|
|
3145
|
+
stroke: paint("annotation-marquee-stroke", chrome.marquee.stroke ?? accent)
|
|
3146
|
+
},
|
|
3147
|
+
textOutline: paint("annotation-text-outline", chrome.textOutline ?? accent)
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
/**
|
|
3151
|
+
* Each chrome node in the page layer's pixels, in drawing order. The plugin
|
|
3152
|
+
* already sized the screen-constant parts (handle grab zones, the rotation
|
|
3153
|
+
* handle's offset) for the page's zoom; this only places them.
|
|
3154
|
+
*/
|
|
3155
|
+
function chromeInPixels(nodes, page) {
|
|
3156
|
+
return nodes.map((node) => {
|
|
3157
|
+
switch (node.kind) {
|
|
3158
|
+
case "handle": return {
|
|
3159
|
+
kind: "handle",
|
|
3160
|
+
at: page.toPixels(node.at),
|
|
3161
|
+
rotation: node.rot ?? 0,
|
|
3162
|
+
role: node.role,
|
|
3163
|
+
active: node.active
|
|
3164
|
+
};
|
|
3165
|
+
case "guide": {
|
|
3166
|
+
const start = node.axis === "x" ? {
|
|
3167
|
+
x: node.at,
|
|
3168
|
+
y: node.lo
|
|
3169
|
+
} : {
|
|
3170
|
+
x: node.lo,
|
|
3171
|
+
y: node.at
|
|
3172
|
+
};
|
|
3173
|
+
const end = node.axis === "x" ? {
|
|
3174
|
+
x: node.at,
|
|
3175
|
+
y: node.hi
|
|
3176
|
+
} : {
|
|
3177
|
+
x: node.hi,
|
|
3178
|
+
y: node.at
|
|
3179
|
+
};
|
|
3180
|
+
return {
|
|
3181
|
+
kind: "guide",
|
|
3182
|
+
from: page.toPixels(start),
|
|
3183
|
+
to: page.toPixels(end)
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
case "obb": return {
|
|
3187
|
+
kind: "turned-outline",
|
|
3188
|
+
points: svgPoints(node.corners.map((corner) => page.toPixels(corner)))
|
|
3189
|
+
};
|
|
3190
|
+
case "rotate-guides": return {
|
|
3191
|
+
kind: "rotation-guides",
|
|
3192
|
+
lines: node.lines.map((chord) => ({
|
|
3193
|
+
from: page.toPixels(chord.a),
|
|
3194
|
+
to: page.toPixels(chord.b),
|
|
3195
|
+
opacity: chord.role === "axis" ? .35 : .8
|
|
3196
|
+
}))
|
|
3197
|
+
};
|
|
3198
|
+
case "rotate-knob": return {
|
|
3199
|
+
kind: "rotation-handle",
|
|
3200
|
+
at: page.toPixels(node.at),
|
|
3201
|
+
from: page.toPixels(node.from)
|
|
3202
|
+
};
|
|
3203
|
+
case "marquee": return {
|
|
3204
|
+
kind: "marquee",
|
|
3205
|
+
box: rectInPixels(node.rect, page)
|
|
3206
|
+
};
|
|
3207
|
+
case "outline": return {
|
|
3208
|
+
kind: "outline",
|
|
3209
|
+
box: rectInPixels(node.rect, page)
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3212
|
+
});
|
|
3213
|
+
}
|
|
3214
|
+
//#endregion
|
|
3215
|
+
//#region src/text-box-editor.ts
|
|
3216
|
+
/**
|
|
3217
|
+
* Make `element` the editor of a text box. Typing shows at once and reaches
|
|
3218
|
+
* the engine in one write after a pause (or when the edit ends), not one
|
|
3219
|
+
* write per keystroke.
|
|
3220
|
+
*/
|
|
3221
|
+
function attachTextBoxEditor(element, annotation) {
|
|
3222
|
+
let item = null;
|
|
3223
|
+
let binding = null;
|
|
3224
|
+
let shownEditing = null;
|
|
3225
|
+
const host = {
|
|
3226
|
+
onInput: (document) => {
|
|
3227
|
+
if (item?.ref) annotation.draftRichText(item.ref, document);
|
|
3228
|
+
},
|
|
3229
|
+
onSelectionChange: (range) => {
|
|
3230
|
+
if (item?.ref) annotation.setTextSelection(item.ref, range);
|
|
3231
|
+
},
|
|
3232
|
+
onCommand: (command) => void annotation.text.toggleFormat(command),
|
|
3233
|
+
cssFontFamily: (family) => annotation.getCssFontFamily(family)
|
|
3234
|
+
};
|
|
3235
|
+
const stopPress = (event) => event.stopPropagation();
|
|
3236
|
+
const refocus = (event) => {
|
|
3237
|
+
if (event.relatedTarget == null && element.isConnected && item && annotation.getEditingId() === item.id) element.focus();
|
|
3238
|
+
};
|
|
3239
|
+
element.addEventListener("pointerdown", stopPress);
|
|
3240
|
+
element.addEventListener("blur", refocus);
|
|
3241
|
+
return {
|
|
3242
|
+
update(next, scale) {
|
|
3243
|
+
item = next;
|
|
3244
|
+
if (next && !binding) binding = attachRichTextEditor(element, host, {
|
|
3245
|
+
document: next.richText,
|
|
3246
|
+
scale
|
|
3247
|
+
});
|
|
3248
|
+
else if (!next && binding) {
|
|
3249
|
+
binding.detach();
|
|
3250
|
+
binding = null;
|
|
3251
|
+
}
|
|
3252
|
+
if (next) binding?.update({
|
|
3253
|
+
document: next.richText,
|
|
3254
|
+
scale
|
|
3255
|
+
});
|
|
3256
|
+
const editing = next?.editing ?? false;
|
|
3257
|
+
if (editing === shownEditing) return;
|
|
3258
|
+
shownEditing = editing;
|
|
3259
|
+
element.contentEditable = editing ? "true" : "false";
|
|
3260
|
+
if (editing) {
|
|
3261
|
+
element.focus();
|
|
3262
|
+
element.scrollTop = 0;
|
|
3263
|
+
}
|
|
3264
|
+
},
|
|
3265
|
+
detach() {
|
|
3266
|
+
binding?.detach();
|
|
3267
|
+
binding = null;
|
|
3268
|
+
element.removeEventListener("pointerdown", stopPress);
|
|
3269
|
+
element.removeEventListener("blur", refocus);
|
|
3270
|
+
}
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* A text box editor that follows its inputs: what each framework's binding
|
|
3275
|
+
* holds between renders. The update runs right after an attach, in the same
|
|
3276
|
+
* call, so the element never shows an editor without its text.
|
|
3277
|
+
*/
|
|
3278
|
+
function createTextBoxEditorFollower() {
|
|
3279
|
+
let attached = null;
|
|
3280
|
+
const detach = () => {
|
|
3281
|
+
attached?.editor.detach();
|
|
3282
|
+
attached = null;
|
|
3283
|
+
};
|
|
3284
|
+
return {
|
|
3285
|
+
follow(element, annotation, item, scale) {
|
|
3286
|
+
if (!(attached !== null && attached.element === element && attached.annotation === annotation)) {
|
|
3287
|
+
detach();
|
|
3288
|
+
if (element && annotation) attached = {
|
|
3289
|
+
element,
|
|
3290
|
+
annotation,
|
|
3291
|
+
editor: attachTextBoxEditor(element, annotation)
|
|
3292
|
+
};
|
|
3293
|
+
}
|
|
3294
|
+
attached?.editor.update(item, scale);
|
|
3295
|
+
},
|
|
3296
|
+
detach
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
/**
|
|
3300
|
+
* Pixels per point for an editor element you draw yourself (a renderer's own
|
|
3301
|
+
* text box): the box's width on the page over its width in points, or the
|
|
3302
|
+
* page's view scale while there is no box. Inside a look the layer scales
|
|
3303
|
+
* (`lookScale`, 1 outside one), the element draws at the annotation's 100%
|
|
3304
|
+
* size and the look's scale does the rest.
|
|
3305
|
+
*/
|
|
3306
|
+
function textBoxEditorScaleOf(item, page, lookScale) {
|
|
3307
|
+
return (item && item.box.width > 0 ? rectInPixels(item.box, page).width / item.box.width : page.viewScale) / lookScale;
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* A text box's body style at `scale` pixels per point: font, size, color and
|
|
3311
|
+
* alignment. No line height: the editor binding states the engine's line
|
|
3312
|
+
* model per face on the element itself.
|
|
3313
|
+
*/
|
|
3314
|
+
function textBoxStyleOf(css, scale) {
|
|
3315
|
+
return {
|
|
3316
|
+
fontFamily: css.fontFamily,
|
|
3317
|
+
fontSize: `${css.fontSize * scale}px`,
|
|
3318
|
+
color: css.color,
|
|
3319
|
+
fontWeight: css.fontWeight,
|
|
3320
|
+
fontStyle: css.fontStyle,
|
|
3321
|
+
textDecoration: css.textDecoration,
|
|
3322
|
+
textAlign: css.align
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
/**
|
|
3326
|
+
* The editor element of a free text box in the page layer's pixels: the
|
|
3327
|
+
* engine's text plate (`SetPlateRect` and its `re W n` clip), the box inset
|
|
3328
|
+
* by its padding. Place it there with no CSS padding, so its edge is the
|
|
3329
|
+
* plate's: CSS padding doesn't clip overflow, and scrolled lines would paint
|
|
3330
|
+
* over the border band. The box never grows with its text.
|
|
3331
|
+
*/
|
|
3332
|
+
function textPlateInPixels(item, page) {
|
|
3333
|
+
const box = rectInPixels(item.box, page);
|
|
3334
|
+
const scale = item.box.width > 0 ? box.width / item.box.width : 1;
|
|
3335
|
+
const inset = item.css.padding * scale;
|
|
3336
|
+
return {
|
|
3337
|
+
left: box.left + inset,
|
|
3338
|
+
top: box.top + inset,
|
|
3339
|
+
width: Math.max(0, box.width - 2 * inset),
|
|
3340
|
+
height: Math.max(0, box.height - 2 * inset),
|
|
3341
|
+
scale
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
//#endregion
|
|
3345
|
+
//#region src/annotation-renderers.ts
|
|
3346
|
+
const isLook = (renderer) => "for" in renderer;
|
|
3347
|
+
/**
|
|
3348
|
+
* The behaviors `interactive` renderers registered, counted per capability
|
|
3349
|
+
* and renderer entry, because the layer mounts once per page: the first page
|
|
3350
|
+
* registers, the last one unregisters. The entry's identity is the key, so a
|
|
3351
|
+
* renderer list must be defined once, never anew on every render.
|
|
3352
|
+
*/
|
|
3353
|
+
const registered = /* @__PURE__ */ new WeakMap();
|
|
3354
|
+
let generatedIds = 0;
|
|
3355
|
+
/**
|
|
3356
|
+
* Register a behavior for each `interactive` look in `renderers`, so the
|
|
3357
|
+
* plugin lets that renderer take the pointer. Call it when a layer mounts (and
|
|
3358
|
+
* again when its renderer list changes); call what it returns when it
|
|
3359
|
+
* unmounts. `activeToolId` is read whenever an `interactive` function is asked.
|
|
3360
|
+
*/
|
|
3361
|
+
function registerRendererBehaviors(annotation, renderers, activeToolId) {
|
|
3362
|
+
const releases = [];
|
|
3363
|
+
for (const renderer of renderers) {
|
|
3364
|
+
if (!isLook(renderer) || !renderer.interactive) continue;
|
|
3365
|
+
let perCapability = registered.get(annotation);
|
|
3366
|
+
if (!perCapability) registered.set(annotation, perCapability = /* @__PURE__ */ new Map());
|
|
3367
|
+
let entry = perCapability.get(renderer);
|
|
3368
|
+
if (!entry) {
|
|
3369
|
+
const id = renderer.id ?? `renderer:${++generatedIds}`;
|
|
3370
|
+
const interactive = renderer.interactive;
|
|
3371
|
+
const engaged = typeof interactive === "function" ? (record) => interactive({
|
|
3372
|
+
annotation: record,
|
|
3373
|
+
toolId: activeToolId()
|
|
3374
|
+
}) : () => true;
|
|
3375
|
+
entry = {
|
|
3376
|
+
id,
|
|
3377
|
+
count: 0,
|
|
3378
|
+
unregister: annotation.registerBehavior({
|
|
3379
|
+
id,
|
|
3380
|
+
matches: renderer.for,
|
|
3381
|
+
engaged
|
|
3382
|
+
})
|
|
3383
|
+
};
|
|
3384
|
+
perCapability.set(renderer, entry);
|
|
3385
|
+
}
|
|
3386
|
+
entry.count++;
|
|
3387
|
+
const owned = entry;
|
|
3388
|
+
releases.push(() => {
|
|
3389
|
+
owned.count--;
|
|
3390
|
+
if (owned.count === 0) {
|
|
3391
|
+
owned.unregister();
|
|
3392
|
+
registered.get(annotation)?.delete(renderer);
|
|
3393
|
+
}
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
return () => releases.forEach((release) => release());
|
|
3397
|
+
}
|
|
3398
|
+
/** The behavior id a renderer entry answers for: a sibling plugin's own, or the one registered for it. */
|
|
3399
|
+
function rendererBehaviorId(annotation, renderer) {
|
|
3400
|
+
if ("behavior" in renderer) return renderer.behavior;
|
|
3401
|
+
return registered.get(annotation)?.get(renderer)?.id ?? null;
|
|
3402
|
+
}
|
|
3403
|
+
/** The first of your looks that draws this annotation, or `null`. */
|
|
3404
|
+
function lookRendererFor(record, renderers) {
|
|
3405
|
+
return renderers?.find((renderer) => isLook(renderer) && renderer.for(record)) ?? null;
|
|
3406
|
+
}
|
|
3407
|
+
/**
|
|
3408
|
+
* The drawing an annotation gets. Ownership beats looks: an engaged
|
|
3409
|
+
* behavior's renderer is authoritative; `for` rules apply only to what the
|
|
3410
|
+
* layer owns, and draw without the pointer. `typing` is whether this
|
|
3411
|
+
* annotation's text box is being typed in.
|
|
3412
|
+
*/
|
|
3413
|
+
function annotationDrawingOf(record, annotation, renderers, typing) {
|
|
3414
|
+
const behavior = annotation.getBehaviorFor(record);
|
|
3415
|
+
if (behavior) {
|
|
3416
|
+
const entry = renderers?.find((renderer) => rendererBehaviorId(annotation, renderer) === behavior.id);
|
|
3417
|
+
if (entry && "behavior" in entry) return {
|
|
3418
|
+
kind: "owned",
|
|
3419
|
+
entry
|
|
3420
|
+
};
|
|
3421
|
+
if (entry) return {
|
|
3422
|
+
kind: "look",
|
|
3423
|
+
entry,
|
|
3424
|
+
interactive: true,
|
|
3425
|
+
inert: false
|
|
3426
|
+
};
|
|
3427
|
+
return {
|
|
3428
|
+
kind: "native",
|
|
3429
|
+
inert: true
|
|
3430
|
+
};
|
|
3431
|
+
}
|
|
3432
|
+
const look = lookRendererFor(record, renderers);
|
|
3433
|
+
if (!look) return {
|
|
3434
|
+
kind: "native",
|
|
3435
|
+
inert: false
|
|
3436
|
+
};
|
|
3437
|
+
return {
|
|
3438
|
+
kind: "look",
|
|
3439
|
+
entry: look,
|
|
3440
|
+
interactive: false,
|
|
3441
|
+
inert: !typing
|
|
3442
|
+
};
|
|
3443
|
+
}
|
|
3444
|
+
/**
|
|
3445
|
+
* The key of the text box being typed in (the one that is `editing` and has a
|
|
3446
|
+
* ref), or `null`. An annotation with this key is `typing` for
|
|
3447
|
+
* {@link annotationDrawingOf}: while it is, a look's own editor takes the keys.
|
|
3448
|
+
*/
|
|
3449
|
+
function editingTextKeyOf(texts, keyOf) {
|
|
3450
|
+
const editing = texts.find((text) => text.editing && text.ref !== null);
|
|
3451
|
+
return editing?.ref ? keyOf(editing.ref) : null;
|
|
3452
|
+
}
|
|
3453
|
+
/**
|
|
3454
|
+
* The text boxes the layer draws itself: all of them but the ones whose
|
|
3455
|
+
* annotation a look of yours draws, which that look edits with its own editor
|
|
3456
|
+
* (a renderer's rich text editor). `items` are the page's render items, which
|
|
3457
|
+
* carry each annotation; the same array comes back when there is no look.
|
|
3458
|
+
*/
|
|
3459
|
+
function layerTextBoxesOf(texts, items, renderers, keyOf) {
|
|
3460
|
+
const looks = renderers?.filter((renderer) => isLook(renderer)) ?? [];
|
|
3461
|
+
if (looks.length === 0) return texts;
|
|
3462
|
+
const annotations = /* @__PURE__ */ new Map();
|
|
3463
|
+
for (const item of items) if (item.ref !== null && item.annotation) annotations.set(keyOf(item.ref), item.annotation);
|
|
3464
|
+
return texts.filter((text) => {
|
|
3465
|
+
const annotation = text.ref !== null ? annotations.get(keyOf(text.ref)) : void 0;
|
|
3466
|
+
return !annotation || lookRendererFor(annotation, looks) === null;
|
|
3467
|
+
});
|
|
3468
|
+
}
|
|
3469
|
+
//#endregion
|
|
3470
|
+
//#region src/object-urls.ts
|
|
3471
|
+
/** An object URL for bytes. Revoke it when nothing shows it any more. */
|
|
3472
|
+
function objectUrlOf(picture) {
|
|
3473
|
+
const body = new ArrayBuffer(picture.bytes.byteLength);
|
|
3474
|
+
new Uint8Array(body).set(picture.bytes);
|
|
3475
|
+
const blob = new Blob([body], picture.mimeType ? { type: picture.mimeType } : {});
|
|
3476
|
+
const url = URL.createObjectURL(blob);
|
|
3477
|
+
return {
|
|
3478
|
+
url,
|
|
3479
|
+
revoke: () => URL.revokeObjectURL(url)
|
|
3480
|
+
};
|
|
3481
|
+
}
|
|
3482
|
+
/**
|
|
3483
|
+
* Load a picture and hand its object URL to `onUrl` once it's there (nothing
|
|
3484
|
+
* for `null`). What it returns cancels: a load still running is dropped, and
|
|
3485
|
+
* the URL is revoked. Keep showing the previous URL until `onUrl` brings the
|
|
3486
|
+
* next one, so a swap never flickers.
|
|
3487
|
+
*/
|
|
3488
|
+
function loadObjectUrl(load, onUrl) {
|
|
3489
|
+
let cancelled = false;
|
|
3490
|
+
let made = null;
|
|
3491
|
+
load().then((picture) => {
|
|
3492
|
+
if (cancelled || !picture) return;
|
|
3493
|
+
made = objectUrlOf(picture);
|
|
3494
|
+
onUrl(made.url);
|
|
3495
|
+
});
|
|
3496
|
+
return () => {
|
|
3497
|
+
cancelled = true;
|
|
3498
|
+
made?.revoke();
|
|
3499
|
+
};
|
|
3500
|
+
}
|
|
3501
|
+
/** A picture's key: its item's key and the state it draws. */
|
|
3502
|
+
const pictureKey = (itemKey, state) => `${itemKey}|${state ?? ""}`;
|
|
3503
|
+
/**
|
|
3504
|
+
* The picture an item shows, from {@link loadAppearanceUrls}: the one of the
|
|
3505
|
+
* state it's in (`'Off'` when it names none), or its only picture when it has
|
|
3506
|
+
* no states; `null` until it's loaded.
|
|
3507
|
+
*/
|
|
3508
|
+
function shownAppearance(urls, itemKey, appearanceState) {
|
|
3509
|
+
return urls[pictureKey(itemKey, appearanceState ?? "Off")] ?? urls[pictureKey(itemKey, null)] ?? null;
|
|
3510
|
+
}
|
|
3511
|
+
/**
|
|
3512
|
+
* The engine's picture of an annotation as a renderer gets it (its
|
|
3513
|
+
* `appearance`): `{ url }` once {@link loadAppearanceUrls} has loaded the one
|
|
3514
|
+
* of the state it shows, else `null`.
|
|
3515
|
+
*/
|
|
3516
|
+
function bakedAppearanceOf(urls, itemKey, appearanceState) {
|
|
3517
|
+
const baked = shownAppearance(urls, itemKey, appearanceState);
|
|
3518
|
+
return baked ? { url: baked.url } : null;
|
|
3519
|
+
}
|
|
3520
|
+
/** What a layer shows, for {@link loadAppearanceUrls}. */
|
|
3521
|
+
function createShownUrls() {
|
|
3522
|
+
let shown = [];
|
|
3523
|
+
const holder = {
|
|
3524
|
+
replace(revokers) {
|
|
3525
|
+
const before = shown;
|
|
3526
|
+
shown = revokers;
|
|
3527
|
+
before.forEach((revoke) => revoke());
|
|
3528
|
+
},
|
|
3529
|
+
release: () => holder.replace([])
|
|
3530
|
+
};
|
|
3531
|
+
return holder;
|
|
3532
|
+
}
|
|
3533
|
+
/**
|
|
3534
|
+
* Load a page's pictures, every state of every item, and hand their URLs to
|
|
3535
|
+
* `onLoaded` once all of them are there; the set `shown` held before is
|
|
3536
|
+
* revoked then. {@link shownAppearance} picks an item's: a check box that
|
|
3537
|
+
* changes shows its other picture at once. What it returns cancels: the load
|
|
3538
|
+
* is aborted and the URLs it made are revoked, unless it already handed them
|
|
3539
|
+
* over. Start it again when the page's appearance epoch or the appearance
|
|
3540
|
+
* scale changes, never mid-gesture.
|
|
3541
|
+
*/
|
|
3542
|
+
function loadAppearanceUrls(shown, load, keyOf, onLoaded) {
|
|
3543
|
+
return loadPictureUrls(shown, load, (picture) => pictureKey(keyOf(picture.ref), picture.state), onLoaded);
|
|
3544
|
+
}
|
|
3545
|
+
function loadPictureUrls(shown, load, keyOf, onLoaded) {
|
|
3546
|
+
const controller = new AbortController();
|
|
3547
|
+
/** The URLs this load made and hasn't handed over. */
|
|
3548
|
+
let made = [];
|
|
3549
|
+
const drop = () => {
|
|
3550
|
+
made.forEach((revoke) => revoke());
|
|
3551
|
+
made = [];
|
|
3552
|
+
};
|
|
3553
|
+
const urlOf = async (picture) => {
|
|
3554
|
+
const url = await picture.image.objectUrl().abortWith(controller.signal);
|
|
3555
|
+
if (controller.signal.aborted) {
|
|
3556
|
+
url.revoke();
|
|
3557
|
+
throw controller.signal.reason;
|
|
3558
|
+
}
|
|
3559
|
+
made.push(url.revoke);
|
|
3560
|
+
return url.url;
|
|
3561
|
+
};
|
|
3562
|
+
(async () => {
|
|
3563
|
+
try {
|
|
3564
|
+
const pictures = await load(controller.signal);
|
|
3565
|
+
const minted = await Promise.all(pictures.map(urlOf));
|
|
3566
|
+
if (controller.signal.aborted) return;
|
|
3567
|
+
const urls = {};
|
|
3568
|
+
pictures.forEach((picture, index) => {
|
|
3569
|
+
urls[keyOf(picture)] = {
|
|
3570
|
+
url: minted[index],
|
|
3571
|
+
box: picture.rect,
|
|
3572
|
+
version: picture.version
|
|
3573
|
+
};
|
|
3574
|
+
});
|
|
3575
|
+
onLoaded(urls);
|
|
3576
|
+
shown.replace(made);
|
|
3577
|
+
made = [];
|
|
3578
|
+
} catch {
|
|
3579
|
+
controller.abort();
|
|
3580
|
+
drop();
|
|
3581
|
+
}
|
|
3582
|
+
})();
|
|
3583
|
+
return () => {
|
|
3584
|
+
controller.abort();
|
|
3585
|
+
drop();
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
//#endregion
|
|
3589
|
+
//#region src/comment-threads.ts
|
|
3590
|
+
/**
|
|
3591
|
+
* Each thread with its page's display index and label. A thread whose page
|
|
3592
|
+
* was just deleted reads `-1` and `'?'` for the one render before it goes.
|
|
3593
|
+
*/
|
|
3594
|
+
function enrichCommentThreads(threads, pages) {
|
|
3595
|
+
const byPageObjectNumber = new Map(pages.map((page) => [page.ref.objectNumber, page]));
|
|
3596
|
+
return threads.map((thread) => {
|
|
3597
|
+
const page = byPageObjectNumber.get(thread.page.objectNumber);
|
|
3598
|
+
return {
|
|
3599
|
+
...thread,
|
|
3600
|
+
pageIndex: page ? page.index : -1,
|
|
3601
|
+
pageLabel: page ? page.label ?? String(page.index + 1) : "?"
|
|
3602
|
+
};
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
//#endregion
|
|
3606
|
+
//#region src/form-field.ts
|
|
3607
|
+
/** The form settings as colors: an unset focus color is the accent, an unset edge the accent at 55%. */
|
|
3608
|
+
function formColorsOf(settings, accent) {
|
|
3609
|
+
return {
|
|
3610
|
+
focus: paint("form-focus", settings.focus.color ?? accent),
|
|
3611
|
+
border: paint("form-field-border", settings.fields.border ?? mixAccent("form-field-border", accent)),
|
|
3612
|
+
background: paint("form-field-background", settings.fields.background),
|
|
3613
|
+
text: paint("form-field-color", settings.fields.color)
|
|
3614
|
+
};
|
|
3615
|
+
}
|
|
3616
|
+
/** One of the 14 standard PDF fonts as CSS: a family that looks like it, its weight and style. */
|
|
3617
|
+
function cssFontOf(pdfFont) {
|
|
3618
|
+
const name = pdfFont ?? "helvetica";
|
|
3619
|
+
return {
|
|
3620
|
+
fontFamily: name.startsWith("courier") ? "\"Courier New\", Courier, monospace" : name.startsWith("times") ? "\"Times New Roman\", Times, serif" : "Helvetica, Arial, sans-serif",
|
|
3621
|
+
fontWeight: name.includes("bold") ? 700 : 400,
|
|
3622
|
+
fontStyle: name.includes("italic") || name.includes("oblique") ? "italic" : "normal"
|
|
3623
|
+
};
|
|
3624
|
+
}
|
|
3625
|
+
/** Pixels per page point where the widget is drawn. */
|
|
3626
|
+
const scaleOf = (field, frame) => field.box.width > 0 ? frame.width / field.box.width : 1;
|
|
3627
|
+
/**
|
|
3628
|
+
* A text field's editor style. A font size of 0 means "fit the box" in PDF:
|
|
3629
|
+
* the box height for one line, Acrobat's 12 pt for several.
|
|
3630
|
+
*/
|
|
3631
|
+
function textFieldStyleOf(field, frame, colors) {
|
|
3632
|
+
const scale = scaleOf(field, frame);
|
|
3633
|
+
const fontSize = field.look.fontSize ? field.look.fontSize * scale : field.multiline ? 12 * scale : Math.max(6, frame.height * .72);
|
|
3634
|
+
return {
|
|
3635
|
+
...cssFontOf(field.look.fontFamily),
|
|
3636
|
+
fontSize: `${fontSize}px`,
|
|
3637
|
+
textAlign: field.look.textAlign,
|
|
3638
|
+
color: field.look.color ?? colors.text,
|
|
3639
|
+
background: field.look.background ?? colors.background,
|
|
3640
|
+
...field.comb && field.maxLength ? { letterSpacing: `${frame.width / field.maxLength / 2}px` } : {}
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
/** A list box's style: the field's own look, the settings where it has none (12 pt for a fitted size). */
|
|
3644
|
+
function listBoxStyleOf(field, frame, colors) {
|
|
3645
|
+
const scale = scaleOf(field, frame);
|
|
3646
|
+
return {
|
|
3647
|
+
...cssFontOf(field.look.fontFamily),
|
|
3648
|
+
fontSize: `${(field.look.fontSize || 12) * scale}px`,
|
|
3649
|
+
textAlign: field.look.textAlign,
|
|
3650
|
+
color: field.look.color ?? colors.text,
|
|
3651
|
+
background: field.look.background ?? colors.background,
|
|
3652
|
+
borderWidth: `${Math.max(1, field.look.borderWidth * scale)}px`,
|
|
3653
|
+
borderStyle: field.look.borderStyle === "dashed" ? "dashed" : "solid",
|
|
3654
|
+
borderColor: field.look.border ?? colors.border
|
|
3655
|
+
};
|
|
3656
|
+
}
|
|
3657
|
+
/**
|
|
3658
|
+
* Send a widget's pointer and focus events (`/AA`: enter, exit, down, up,
|
|
3659
|
+
* focus, blur) to `notify`, from native listeners on the widget's box. Put
|
|
3660
|
+
* them on the always-active box, never on the control inside: "may edit this
|
|
3661
|
+
* field" and "may receive PDF action events" are different rights, and a
|
|
3662
|
+
* session that may not fill still sees hover tooltips. Focus is `focusin` and
|
|
3663
|
+
* `focusout`, so the control's focus reaches the box, and a blur arrives
|
|
3664
|
+
* after the control's own blur handler started its commit. Returns the
|
|
3665
|
+
* detach.
|
|
3666
|
+
*/
|
|
3667
|
+
function bindWidgetEvents(box, notify) {
|
|
3668
|
+
const handlers = [
|
|
3669
|
+
["pointerenter", "cursorEnter"],
|
|
3670
|
+
["pointerleave", "cursorExit"],
|
|
3671
|
+
["pointerdown", "mouseDown"],
|
|
3672
|
+
["pointerup", "mouseUp"],
|
|
3673
|
+
["focusin", "focus"],
|
|
3674
|
+
["focusout", "blur"]
|
|
3675
|
+
].map(([type, kind]) => {
|
|
3676
|
+
const handler = () => notify(kind);
|
|
3677
|
+
box.addEventListener(type, handler);
|
|
3678
|
+
return [type, handler];
|
|
3679
|
+
});
|
|
3680
|
+
return () => handlers.forEach(([type, handler]) => box.removeEventListener(type, handler));
|
|
3681
|
+
}
|
|
3682
|
+
/**
|
|
3683
|
+
* A checkbox or radio button pressed: a checked checkbox clears, a radio
|
|
3684
|
+
* button always selects its own value. The value is written first, then the
|
|
3685
|
+
* widget's action runs (Acrobat's order), so an action script reads the new
|
|
3686
|
+
* state; a read-only toggle writes nothing and still runs its action.
|
|
3687
|
+
*/
|
|
3688
|
+
function pressToggle(form, toggle, activate) {
|
|
3689
|
+
const written = toggle.disabled ? void 0 : form.setValue(toggle.fieldRef, { value: toggle.kind === "checkbox" && toggle.checked ? null : toggle.exportValue });
|
|
3690
|
+
Promise.resolve(written).then(activate, activate);
|
|
3691
|
+
}
|
|
3692
|
+
/**
|
|
3693
|
+
* The editing policy of one text field: typing drafts, blur or Enter
|
|
3694
|
+
* commits, Escape discards. A commit keeps the typed text until the field
|
|
3695
|
+
* shows it (at once, as the form shows a fill before the engine answers), so
|
|
3696
|
+
* a blur never shows the old value for a moment. A value the form's
|
|
3697
|
+
* validation refuses stays typed, as in Acrobat; a write the engine refuses
|
|
3698
|
+
* puts the field's value back.
|
|
3699
|
+
*/
|
|
3700
|
+
function createTextFieldEditor(form, field, value) {
|
|
3701
|
+
let state = {
|
|
3702
|
+
focused: false,
|
|
3703
|
+
draft: value
|
|
3704
|
+
};
|
|
3705
|
+
let fieldValue = value;
|
|
3706
|
+
let cancelled = false;
|
|
3707
|
+
/** The text of the commit on its way, until the field shows it or the commit settles. */
|
|
3708
|
+
let committing = null;
|
|
3709
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
3710
|
+
const set = (patch) => {
|
|
3711
|
+
const next = {
|
|
3712
|
+
...state,
|
|
3713
|
+
...patch
|
|
3714
|
+
};
|
|
3715
|
+
if (next.focused === state.focused && next.draft === state.draft) return;
|
|
3716
|
+
state = next;
|
|
3717
|
+
listeners.forEach((listener) => listener());
|
|
3718
|
+
};
|
|
3719
|
+
const commit = (typed) => {
|
|
3720
|
+
committing = typed;
|
|
3721
|
+
const settled = (keepTyped) => {
|
|
3722
|
+
if (committing !== typed) return;
|
|
3723
|
+
committing = null;
|
|
3724
|
+
if (!state.focused) set({ draft: keepTyped ? typed : fieldValue });
|
|
3725
|
+
};
|
|
3726
|
+
form.commitDraftText(field).then((result) => settled(result?.status === "rejected"), () => settled(false));
|
|
3727
|
+
};
|
|
3728
|
+
return {
|
|
3729
|
+
getState: () => state,
|
|
3730
|
+
subscribe(listener) {
|
|
3731
|
+
listeners.add(listener);
|
|
3732
|
+
return () => listeners.delete(listener);
|
|
3733
|
+
},
|
|
3734
|
+
setValue(next) {
|
|
3735
|
+
fieldValue = next;
|
|
3736
|
+
if (state.focused || committing !== null && next !== committing) return;
|
|
3737
|
+
committing = null;
|
|
3738
|
+
set({ draft: next });
|
|
3739
|
+
},
|
|
3740
|
+
focus: () => set({ focused: true }),
|
|
3741
|
+
input(text) {
|
|
3742
|
+
set({ draft: text });
|
|
3743
|
+
form.draftText(field, text);
|
|
3744
|
+
},
|
|
3745
|
+
keyDown(key, multiline) {
|
|
3746
|
+
if (key === "Escape") {
|
|
3747
|
+
cancelled = true;
|
|
3748
|
+
return true;
|
|
3749
|
+
}
|
|
3750
|
+
return key === "Enter" && !multiline;
|
|
3751
|
+
},
|
|
3752
|
+
blur() {
|
|
3753
|
+
const typed = state.draft;
|
|
3754
|
+
if (cancelled || typed === fieldValue) {
|
|
3755
|
+
cancelled = false;
|
|
3756
|
+
form.discardDraftText(field);
|
|
3757
|
+
set({
|
|
3758
|
+
focused: false,
|
|
3759
|
+
draft: fieldValue
|
|
3760
|
+
});
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3763
|
+
commit(typed);
|
|
3764
|
+
set({
|
|
3765
|
+
focused: false,
|
|
3766
|
+
draft: typed
|
|
3767
|
+
});
|
|
3768
|
+
}
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
/** A list box's optimistic selection, starting at the engine's. */
|
|
3772
|
+
function createOptimisticSelection(confirmed) {
|
|
3773
|
+
let shown = [...confirmed];
|
|
3774
|
+
let engine = confirmed;
|
|
3775
|
+
let engineSignature = JSON.stringify(confirmed);
|
|
3776
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
3777
|
+
const show = (values) => {
|
|
3778
|
+
shown = [...values];
|
|
3779
|
+
listeners.forEach((listener) => listener());
|
|
3780
|
+
};
|
|
3781
|
+
const rollback = () => show(engine);
|
|
3782
|
+
return {
|
|
3783
|
+
get: () => shown,
|
|
3784
|
+
subscribe(listener) {
|
|
3785
|
+
listeners.add(listener);
|
|
3786
|
+
return () => listeners.delete(listener);
|
|
3787
|
+
},
|
|
3788
|
+
setConfirmed(values) {
|
|
3789
|
+
engine = values;
|
|
3790
|
+
const signature = JSON.stringify(values);
|
|
3791
|
+
if (signature === engineSignature) return;
|
|
3792
|
+
engineSignature = signature;
|
|
3793
|
+
show(values);
|
|
3794
|
+
},
|
|
3795
|
+
choose(values, write) {
|
|
3796
|
+
show(values);
|
|
3797
|
+
try {
|
|
3798
|
+
const result = write(values);
|
|
3799
|
+
if (result) result.then(void 0, rollback);
|
|
3800
|
+
} catch {
|
|
3801
|
+
rollback();
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
/**
|
|
3807
|
+
* Show `values` as a native `<select>`'s selection, set on each option. A
|
|
3808
|
+
* `value` binding can't hold several values, and setting the options also
|
|
3809
|
+
* puts a choice back when its write fails before the framework renders
|
|
3810
|
+
* again. Setting the same selection again changes nothing the user sees,
|
|
3811
|
+
* scroll position included.
|
|
3812
|
+
*/
|
|
3813
|
+
function showSelectedOptions(select, values) {
|
|
3814
|
+
for (const option of Array.from(select.options)) option.selected = values.includes(option.value);
|
|
3815
|
+
}
|
|
3816
|
+
//#endregion
|
|
3817
|
+
//#region src/form-styles.ts
|
|
3818
|
+
/**
|
|
3819
|
+
* The styles of the form layer's elements, the same in every framework: the
|
|
3820
|
+
* box over each widget, the text field's editor, the list box and the focus
|
|
3821
|
+
* ring. Each is a style record (camelCase names, lengths with their units),
|
|
3822
|
+
* the shape a style object takes in React, Vue and Angular; Svelte writes one
|
|
3823
|
+
* as CSS text with `cssText`. Each is a type alias, so it fits Vue's
|
|
3824
|
+
* `CSSProperties` (see the note in `form-field`). The field's own look inside them comes from
|
|
3825
|
+
* `form-field` ({@link textFieldStyleOf}, {@link listBoxStyleOf}); its colors
|
|
3826
|
+
* from the form settings ({@link formColorsOf}).
|
|
3827
|
+
*/
|
|
3828
|
+
/** A control that fills the widget's box it sits in: every native control the layer draws. */
|
|
3829
|
+
const FORM_CONTROL_FILL = Object.freeze({
|
|
3830
|
+
position: "absolute",
|
|
3831
|
+
inset: "0",
|
|
3832
|
+
width: "100%",
|
|
3833
|
+
height: "100%",
|
|
3834
|
+
boxSizing: "border-box",
|
|
3835
|
+
margin: "0"
|
|
3836
|
+
});
|
|
3837
|
+
/**
|
|
3838
|
+
* The style of a widget's box at `frame` (the widget's box in the page
|
|
3839
|
+
* layer's pixels). `edge: false` leaves the edge out, for a control that draws
|
|
3840
|
+
* its own (a list box) or never has one (a button).
|
|
3841
|
+
*/
|
|
3842
|
+
function widgetBoxStyleOf(widget, frame, colors, { edge = true } = {}) {
|
|
3843
|
+
return {
|
|
3844
|
+
position: "absolute",
|
|
3845
|
+
left: `${frame.left}px`,
|
|
3846
|
+
top: `${frame.top}px`,
|
|
3847
|
+
width: `${frame.width}px`,
|
|
3848
|
+
height: `${frame.height}px`,
|
|
3849
|
+
pointerEvents: "auto",
|
|
3850
|
+
...edge && widget.look.border === null ? { boxShadow: `inset 0 0 0 1px ${colors.border}` } : {}
|
|
3851
|
+
};
|
|
3852
|
+
}
|
|
3853
|
+
/**
|
|
3854
|
+
* The style of a text field's editor, the `<input>` or `<textarea>` that is
|
|
3855
|
+
* always there: focused, it shows the field's text in its own font with a
|
|
3856
|
+
* focus ring; at rest it is see-through over the field's picture. A disabled
|
|
3857
|
+
* control would swallow clicks, and the box below runs the widget's action
|
|
3858
|
+
* (the read-only "button" pattern), so a disabled one lets them through.
|
|
3859
|
+
*
|
|
3860
|
+
* With the engine's `layout` of the text being typed, the editor writes it
|
|
3861
|
+
* where the engine will draw it: in the engine's face (mounted), at the size
|
|
3862
|
+
* it draws (a fitted size resolved), in the box inside the border, a line of
|
|
3863
|
+
* the face's height (one line centred, as the engine centres it).
|
|
3864
|
+
*/
|
|
3865
|
+
function textFieldEditorStyleOf(field, frame, colors, focused, layout = null) {
|
|
3866
|
+
const scale = scaleOf(field, frame);
|
|
3867
|
+
const pixels = (points) => `${points * scale}px`;
|
|
3868
|
+
return {
|
|
3869
|
+
...FORM_CONTROL_FILL,
|
|
3870
|
+
border: "none",
|
|
3871
|
+
outline: focused ? `2px solid ${colors.focus}` : "none",
|
|
3872
|
+
outlineOffset: "-2px",
|
|
3873
|
+
padding: "0 2px",
|
|
3874
|
+
...textFieldStyleOf(field, frame, colors),
|
|
3875
|
+
...layout ? {
|
|
3876
|
+
fontFamily: layout.fontFamily,
|
|
3877
|
+
fontWeight: layout.fontWeight,
|
|
3878
|
+
fontStyle: layout.fontStyle,
|
|
3879
|
+
fontSize: pixels(layout.fontSize),
|
|
3880
|
+
lineHeight: pixels(layout.lineHeight),
|
|
3881
|
+
padding: [
|
|
3882
|
+
layout.padding.top,
|
|
3883
|
+
layout.padding.right,
|
|
3884
|
+
layout.padding.bottom,
|
|
3885
|
+
layout.padding.left
|
|
3886
|
+
].map(pixels).join(" "),
|
|
3887
|
+
...layout.comb ? {
|
|
3888
|
+
letterSpacing: pixels(layout.letterSpacing),
|
|
3889
|
+
textAlign: "left"
|
|
3890
|
+
} : {}
|
|
3891
|
+
} : {},
|
|
3892
|
+
resize: "none",
|
|
3893
|
+
cursor: "text",
|
|
3894
|
+
opacity: focused ? 1 : 0,
|
|
3895
|
+
...field.disabled ? { pointerEvents: "none" } : {}
|
|
3896
|
+
};
|
|
3897
|
+
}
|
|
3898
|
+
/**
|
|
3899
|
+
* The style of a list box's `<select>`, filling the widget's box: the field's
|
|
3900
|
+
* own look ({@link listBoxStyleOf}) with its own border, so the box adds no
|
|
3901
|
+
* edge. The focus ring is drawn above it ({@link formFocusRingStyleOf}).
|
|
3902
|
+
*/
|
|
3903
|
+
function listBoxControlStyleOf(field, frame, colors) {
|
|
3904
|
+
return {
|
|
3905
|
+
...FORM_CONTROL_FILL,
|
|
3906
|
+
padding: "0",
|
|
3907
|
+
borderRadius: "0",
|
|
3908
|
+
outline: "none",
|
|
3909
|
+
...listBoxStyleOf(field, frame, colors),
|
|
3910
|
+
cursor: field.disabled ? "default" : "pointer",
|
|
3911
|
+
...field.disabled ? { pointerEvents: "none" } : {}
|
|
3912
|
+
};
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* The style of a field's focus ring in `color` (the form's focus color): an
|
|
3916
|
+
* element of its own above the field's picture and its control. An outline on
|
|
3917
|
+
* the box would sit under an opaque child, so a rectangular checkbox or a
|
|
3918
|
+
* dropdown would look unfocused while it has the focus.
|
|
3919
|
+
*/
|
|
3920
|
+
function formFocusRingStyleOf(color) {
|
|
3921
|
+
return {
|
|
3922
|
+
position: "absolute",
|
|
3923
|
+
inset: "0",
|
|
3924
|
+
zIndex: 1,
|
|
3925
|
+
boxSizing: "border-box",
|
|
3926
|
+
outline: `2px solid ${color}`,
|
|
3927
|
+
outlineOffset: "-2px",
|
|
3928
|
+
pointerEvents: "none"
|
|
3929
|
+
};
|
|
3930
|
+
}
|
|
3931
|
+
//#endregion
|
|
3932
|
+
//#region src/link-anchor.ts
|
|
3933
|
+
/**
|
|
3934
|
+
* A page link's anchor: which links a page layer shows, where each anchor
|
|
3935
|
+
* sits and what it does natively. Links are real `<a>` elements on purpose: a website
|
|
3936
|
+
* link gets an `href` (middle-click, copy link, the status-bar preview and
|
|
3937
|
+
* keyboard focus come free); every other click follows the link through the
|
|
3938
|
+
* link plugin's `activate()`. The plugin's link type is mirrored
|
|
3939
|
+
* structurally, so this package stays free of EmbedPDF imports.
|
|
3940
|
+
*/
|
|
3941
|
+
/**
|
|
3942
|
+
* The links a page layer shows anchors for. One owner per pixel: while the
|
|
3943
|
+
* active tool edits annotations, a link attached to an annotation stands
|
|
3944
|
+
* down so its parent can be selected and moved; a document's own links
|
|
3945
|
+
* navigate under every tool that enables link navigation.
|
|
3946
|
+
*/
|
|
3947
|
+
function navigableLinksOf(links, annotationEditEnabled) {
|
|
3948
|
+
return annotationEditEnabled ? links.filter((link) => !link.attached) : links;
|
|
3949
|
+
}
|
|
3950
|
+
/**
|
|
3951
|
+
* The anchor's native `href`: only for a website the browser may open and
|
|
3952
|
+
* nothing chained after it. Blocked schemes, targets inside the document and
|
|
3953
|
+
* action chains activate through the plugin instead: a native navigation
|
|
3954
|
+
* would run the first action and silently drop the rest.
|
|
3955
|
+
*/
|
|
3956
|
+
function linkHrefOf(link) {
|
|
3957
|
+
const chained = (link.activate?.root?.next.length ?? 0) > 0;
|
|
3958
|
+
if (link.target.kind !== "uri" || chained || link.target.uri === void 0) return null;
|
|
3959
|
+
return sanitizeExternalUri(link.target.uri);
|
|
3960
|
+
}
|
|
3961
|
+
/**
|
|
3962
|
+
* The anchor for `link` on a page layer: its box from the link's `bounds`,
|
|
3963
|
+
* its `href`, and the label `labelOf` (the link plugin's `getLabel`) gives it.
|
|
3964
|
+
*/
|
|
3965
|
+
function linkAnchorOf(link, page, labelOf) {
|
|
3966
|
+
return {
|
|
3967
|
+
link,
|
|
3968
|
+
box: rectInPixels(link.bounds, page),
|
|
3969
|
+
href: linkHrefOf(link),
|
|
3970
|
+
label: labelOf(link)
|
|
3971
|
+
};
|
|
3972
|
+
}
|
|
3973
|
+
/**
|
|
3974
|
+
* Whether a click on an anchor with an `href` stays the browser's: a click
|
|
3975
|
+
* with a modifier key opens a background tab or a new window. Every other
|
|
3976
|
+
* click is prevented and follows the link through the plugin.
|
|
3977
|
+
*/
|
|
3978
|
+
function isModifiedClick(event) {
|
|
3979
|
+
return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
3980
|
+
}
|
|
3981
|
+
//#endregion
|
|
3982
|
+
//#region src/link-events.ts
|
|
3983
|
+
/**
|
|
3984
|
+
* The context a click or a key follows `link` with: its `/A` tree (the
|
|
3985
|
+
* actions plugin runs it instead of the target), the annotation it is, its
|
|
3986
|
+
* page, and the view it was followed in (a page destination moves that one).
|
|
3987
|
+
*/
|
|
3988
|
+
function linkActivateContextOf(link, page, stage) {
|
|
3989
|
+
return {
|
|
3990
|
+
activate: link.activate,
|
|
3991
|
+
ref: link.ref,
|
|
3992
|
+
page,
|
|
3993
|
+
...stage ? { stage } : {}
|
|
3994
|
+
};
|
|
3995
|
+
}
|
|
3996
|
+
/**
|
|
3997
|
+
* Send `link`'s own event for a press, a release or a focus change: only for
|
|
3998
|
+
* a link that is an annotation, and only with the actions plugin.
|
|
3999
|
+
*/
|
|
4000
|
+
function sendLinkEvent(actions, link, page, event) {
|
|
4001
|
+
if (!actions || !link.ref) return;
|
|
4002
|
+
const source = {
|
|
4003
|
+
kind: "link",
|
|
4004
|
+
annotation: link.ref,
|
|
4005
|
+
page
|
|
4006
|
+
};
|
|
4007
|
+
actions.dispatch({
|
|
4008
|
+
scope: "annotation",
|
|
4009
|
+
event,
|
|
4010
|
+
ref: link.ref,
|
|
4011
|
+
page,
|
|
4012
|
+
source
|
|
4013
|
+
});
|
|
4014
|
+
}
|
|
4015
|
+
/**
|
|
4016
|
+
* Tell the layer's hover pump the pointer entered `link`: only a link that is
|
|
4017
|
+
* an annotation with hover trees has enter and exit events. Leaving is
|
|
4018
|
+
* `pump.hover(null)`.
|
|
4019
|
+
*/
|
|
4020
|
+
function hoverLink(pump, link, page) {
|
|
4021
|
+
if (!pump || !link.ref || !link.hoverEvents) return;
|
|
4022
|
+
pump.hover({
|
|
4023
|
+
ref: link.ref,
|
|
4024
|
+
page,
|
|
4025
|
+
source: {
|
|
4026
|
+
kind: "link",
|
|
4027
|
+
annotation: link.ref,
|
|
4028
|
+
page
|
|
4029
|
+
},
|
|
4030
|
+
events: link.hoverEvents
|
|
4031
|
+
});
|
|
4032
|
+
}
|
|
4033
|
+
//#endregion
|
|
4034
|
+
//#region src/search-highlights.ts
|
|
4035
|
+
/**
|
|
4036
|
+
* A search layer's paint: each line of each match on a page, as a piece to
|
|
4037
|
+
* draw in the page layer's pixels. An upright line is a box (a positioned
|
|
4038
|
+
* element with rounded corners); a turned one is its true quad (an SVG
|
|
4039
|
+
* polygon). The active match's lines take the active color. The plugin's hit
|
|
4040
|
+
* is mirrored structurally, so this package stays free of EmbedPDF imports.
|
|
4041
|
+
*/
|
|
4042
|
+
/** Every line of `hits` as a piece to draw, in hit order. */
|
|
4043
|
+
function searchHighlightsOf(hits, options) {
|
|
4044
|
+
const { page } = options;
|
|
4045
|
+
return hits.flatMap((hit) => hit.segments.map(({ quad }, line) => {
|
|
4046
|
+
const active = hit === options.active;
|
|
4047
|
+
const fill = active ? options.activeColor : options.color;
|
|
4048
|
+
const key = `${hit.start}:${line}`;
|
|
4049
|
+
if (!isUprightQuad(quad)) return {
|
|
4050
|
+
key,
|
|
4051
|
+
hit,
|
|
4052
|
+
active,
|
|
4053
|
+
fill,
|
|
4054
|
+
box: null,
|
|
4055
|
+
points: svgPoints(quadInPixels(quad, page))
|
|
4056
|
+
};
|
|
4057
|
+
const topLeft = page.toPixels(quad.upperLeft);
|
|
4058
|
+
const bottomRight = page.toPixels(quad.lowerRight);
|
|
4059
|
+
return {
|
|
4060
|
+
key,
|
|
4061
|
+
hit,
|
|
4062
|
+
active,
|
|
4063
|
+
fill,
|
|
4064
|
+
box: {
|
|
4065
|
+
left: topLeft.x,
|
|
4066
|
+
top: topLeft.y,
|
|
4067
|
+
width: bottomRight.x - topLeft.x,
|
|
4068
|
+
height: bottomRight.y - topLeft.y
|
|
4069
|
+
},
|
|
4070
|
+
points: null
|
|
4071
|
+
};
|
|
4072
|
+
}));
|
|
4073
|
+
}
|
|
4074
|
+
//#endregion
|
|
4075
|
+
//#region src/press-click.ts
|
|
4076
|
+
/**
|
|
4077
|
+
* Telling a click from a drag on something drawn over a page (a search
|
|
4078
|
+
* match). The press still reaches the page, so a drag that starts there
|
|
4079
|
+
* selects text; only a press that stays put counts as a click.
|
|
4080
|
+
*/
|
|
4081
|
+
/**
|
|
4082
|
+
* How far (CSS px) a press may travel and still count as a click. A mouse or
|
|
4083
|
+
* pen press that travels 4 px starts selecting text (the selection plugin's
|
|
4084
|
+
* default drag threshold); a finger wobbles more on its own, and the Stage
|
|
4085
|
+
* treats a touch that stays within 10 px as a tap.
|
|
4086
|
+
*/
|
|
4087
|
+
const CLICK_SLOP_PX = {
|
|
4088
|
+
mouse: 4,
|
|
4089
|
+
pen: 4,
|
|
4090
|
+
touch: 10
|
|
4091
|
+
};
|
|
4092
|
+
function createClickDetector() {
|
|
4093
|
+
let started = null;
|
|
4094
|
+
return {
|
|
4095
|
+
press(event) {
|
|
4096
|
+
started = {
|
|
4097
|
+
x: event.clientX,
|
|
4098
|
+
y: event.clientY,
|
|
4099
|
+
slop: CLICK_SLOP_PX[event.pointerType] ?? CLICK_SLOP_PX.mouse
|
|
4100
|
+
};
|
|
4101
|
+
},
|
|
4102
|
+
isClick(event) {
|
|
4103
|
+
const start = started;
|
|
4104
|
+
started = null;
|
|
4105
|
+
return !start || Math.hypot(event.clientX - start.x, event.clientY - start.y) < start.slop;
|
|
4106
|
+
}
|
|
4107
|
+
};
|
|
4108
|
+
}
|
|
4109
|
+
//#endregion
|
|
4110
|
+
//#region src/page-projector.ts
|
|
4111
|
+
/**
|
|
4112
|
+
* A client-space projector for one page. `mounted` says whether the page
|
|
4113
|
+
* element is in the document yet: until it is, every projection is `null`,
|
|
4114
|
+
* and the anchored UI waits for the first measurable pass. The view is the
|
|
4115
|
+
* window. Pair it with {@link observeClientGeometry}, which reports the
|
|
4116
|
+
* browser-driven moves (document scroll, window resize) no state announces.
|
|
4117
|
+
* Every member reads `page` when it runs, so a page context whose members
|
|
4118
|
+
* change keeps one projector.
|
|
4119
|
+
*/
|
|
4120
|
+
function clientPageProjector(page, mounted) {
|
|
4121
|
+
const isThisPage = (other) => other.objectNumber === page.ref.objectNumber;
|
|
4122
|
+
const transformOf = () => typeof page.transform === "function" ? page.transform() : page.transform;
|
|
4123
|
+
return {
|
|
4124
|
+
space: "client",
|
|
4125
|
+
toScreen: (other, rect) => isThisPage(other) && mounted() ? page.toClientRect(rect) : null,
|
|
4126
|
+
toScreenPoint: (other, at) => isThisPage(other) && mounted() ? page.toClientPoint(at) : null,
|
|
4127
|
+
viewEnv: (other) => {
|
|
4128
|
+
if (!isThisPage(other)) return null;
|
|
4129
|
+
const transform = transformOf();
|
|
4130
|
+
return {
|
|
4131
|
+
scale: transform.viewScale,
|
|
4132
|
+
rotation: transform.rotation,
|
|
4133
|
+
zoom: transform.zoom
|
|
4134
|
+
};
|
|
4135
|
+
},
|
|
4136
|
+
view: () => typeof document === "undefined" ? null : {
|
|
4137
|
+
x: 0,
|
|
4138
|
+
y: 0,
|
|
4139
|
+
width: document.documentElement.clientWidth,
|
|
4140
|
+
height: document.documentElement.clientHeight
|
|
4141
|
+
}
|
|
4142
|
+
};
|
|
4143
|
+
}
|
|
4144
|
+
/**
|
|
4145
|
+
* The Stage's projector: anchored UI positions through the camera, in the Stage's own box
|
|
4146
|
+
* (`'overlay'` space). `stage()` gives the lens when a projection runs, or null without one
|
|
4147
|
+
* (nothing projects then). It reads only the Stage's state, so the binding's revision (the
|
|
4148
|
+
* visible pages) is what tells anchored UI to place itself again, in the same render as the
|
|
4149
|
+
* pages.
|
|
4150
|
+
*/
|
|
4151
|
+
function stageViewProjector(stage) {
|
|
4152
|
+
return {
|
|
4153
|
+
space: "overlay",
|
|
4154
|
+
toScreen: (page, rect) => stage()?.pageRectToViewport(page, rect) ?? null,
|
|
4155
|
+
toScreenPoint: (page, at) => {
|
|
4156
|
+
const rect = stage()?.pageRectToViewport(page, {
|
|
4157
|
+
x: at.x,
|
|
4158
|
+
y: at.y,
|
|
4159
|
+
width: 0,
|
|
4160
|
+
height: 0
|
|
4161
|
+
});
|
|
4162
|
+
return rect ? {
|
|
4163
|
+
x: rect.x,
|
|
4164
|
+
y: rect.y
|
|
4165
|
+
} : null;
|
|
4166
|
+
},
|
|
4167
|
+
viewEnv: (page) => {
|
|
4168
|
+
const transform = stage()?.getPageFrame(page)?.transform;
|
|
4169
|
+
return transform ? {
|
|
4170
|
+
scale: transform.viewScale,
|
|
4171
|
+
rotation: transform.rotation,
|
|
4172
|
+
zoom: transform.zoom
|
|
4173
|
+
} : null;
|
|
4174
|
+
},
|
|
4175
|
+
view: () => {
|
|
4176
|
+
const size = stage()?.getViewportSize();
|
|
4177
|
+
return size && size.width > 0 && size.height > 0 ? {
|
|
4178
|
+
x: 0,
|
|
4179
|
+
y: 0,
|
|
4180
|
+
...size
|
|
4181
|
+
} : null;
|
|
4182
|
+
}
|
|
4183
|
+
};
|
|
4184
|
+
}
|
|
4185
|
+
//#endregion
|
|
4186
|
+
//#region src/page-context.ts
|
|
4187
|
+
/**
|
|
4188
|
+
* The client-space conversions of a page whose content box `box()` measures on screen when a
|
|
4189
|
+
* conversion runs: client pixels to box-local view pixels, then the transform undoes the turn
|
|
4190
|
+
* and the scale. `toPagePoint` and `toClientPoint` read the same live box, so they never drift.
|
|
4191
|
+
*/
|
|
4192
|
+
function pageClientSpace(transform, box) {
|
|
4193
|
+
return {
|
|
4194
|
+
toPagePoint: (clientX, clientY) => {
|
|
4195
|
+
const origin = box();
|
|
4196
|
+
return transform().viewToPage({
|
|
4197
|
+
x: clientX - origin.left,
|
|
4198
|
+
y: clientY - origin.top
|
|
4199
|
+
});
|
|
4200
|
+
},
|
|
4201
|
+
toClientPoint: (point) => {
|
|
4202
|
+
const origin = box();
|
|
4203
|
+
const viewPoint = transform().pageToView(point);
|
|
4204
|
+
return {
|
|
4205
|
+
x: origin.left + viewPoint.x,
|
|
4206
|
+
y: origin.top + viewPoint.y
|
|
4207
|
+
};
|
|
4208
|
+
},
|
|
4209
|
+
toClientRect: (rect) => {
|
|
4210
|
+
const origin = box();
|
|
4211
|
+
const viewRect = transform().pageToViewRect(rect);
|
|
4212
|
+
return {
|
|
4213
|
+
x: origin.left + viewRect.x,
|
|
4214
|
+
y: origin.top + viewRect.y,
|
|
4215
|
+
width: viewRect.width,
|
|
4216
|
+
height: viewRect.height
|
|
4217
|
+
};
|
|
4218
|
+
}
|
|
4219
|
+
};
|
|
4220
|
+
}
|
|
4221
|
+
/**
|
|
4222
|
+
* Build a page context. `getRect` is the page's content box on screen (the turned content
|
|
4223
|
+
* element's bounding box), read when a conversion runs.
|
|
4224
|
+
*/
|
|
4225
|
+
function makePageContext(documentId, view, ref, pageIndex, frame, transform, getRect, getViewDemand) {
|
|
4226
|
+
return {
|
|
4227
|
+
documentId,
|
|
4228
|
+
view,
|
|
4229
|
+
ref,
|
|
4230
|
+
pageIndex,
|
|
4231
|
+
frame,
|
|
4232
|
+
transform,
|
|
4233
|
+
...getViewDemand ? { getViewDemand } : {},
|
|
4234
|
+
...pageClientSpace(() => transform, getRect)
|
|
4235
|
+
};
|
|
4236
|
+
}
|
|
4237
|
+
/**
|
|
4238
|
+
* A page context that reads every member through `current()`: the same object for a surface's
|
|
4239
|
+
* lifetime, current in what it answers. For an adapter whose layers keep the object they were
|
|
4240
|
+
* given once (a Svelte component reads `page.transform` in a template and follows the zoom).
|
|
4241
|
+
*/
|
|
4242
|
+
function livePageContext(current) {
|
|
4243
|
+
return {
|
|
4244
|
+
get documentId() {
|
|
4245
|
+
return current().documentId;
|
|
4246
|
+
},
|
|
4247
|
+
get ref() {
|
|
4248
|
+
return current().ref;
|
|
4249
|
+
},
|
|
4250
|
+
get pageIndex() {
|
|
4251
|
+
return current().pageIndex;
|
|
4252
|
+
},
|
|
4253
|
+
get frame() {
|
|
4254
|
+
return current().frame;
|
|
4255
|
+
},
|
|
4256
|
+
get transform() {
|
|
4257
|
+
return current().transform;
|
|
4258
|
+
},
|
|
4259
|
+
get view() {
|
|
4260
|
+
return current().view;
|
|
4261
|
+
},
|
|
4262
|
+
get getViewDemand() {
|
|
4263
|
+
return current().getViewDemand;
|
|
4264
|
+
},
|
|
4265
|
+
toPagePoint: (clientX, clientY) => current().toPagePoint(clientX, clientY),
|
|
4266
|
+
toClientPoint: (point) => current().toClientPoint(point),
|
|
4267
|
+
toClientRect: (rect) => current().toClientRect(rect)
|
|
4268
|
+
};
|
|
4269
|
+
}
|
|
4270
|
+
//#endregion
|
|
4271
|
+
//#region src/page-layers.ts
|
|
4272
|
+
/** Every answer `painted()` gives, one object each, so the same answer is the same object. */
|
|
4273
|
+
const ANSWERS = [false, true].map((annotations) => [false, true].map((formFields) => Object.freeze({
|
|
4274
|
+
annotations,
|
|
4275
|
+
formFields
|
|
4276
|
+
})));
|
|
4277
|
+
const pages = /* @__PURE__ */ new WeakMap();
|
|
4278
|
+
/** The layers of one page surface, by the page ref it hands its layers. */
|
|
4279
|
+
function pageLayersOf(pageRef) {
|
|
4280
|
+
let layers = pages.get(pageRef);
|
|
4281
|
+
if (!layers) {
|
|
4282
|
+
layers = createPageLayers();
|
|
4283
|
+
pages.set(pageRef, layers);
|
|
4284
|
+
}
|
|
4285
|
+
return layers;
|
|
4286
|
+
}
|
|
4287
|
+
function createPageLayers() {
|
|
4288
|
+
const painters = {
|
|
4289
|
+
annotations: 0,
|
|
4290
|
+
formFields: 0
|
|
4291
|
+
};
|
|
4292
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
4293
|
+
let painted = ANSWERS[0][0];
|
|
4294
|
+
let notifying = false;
|
|
4295
|
+
const update = () => {
|
|
4296
|
+
const next = ANSWERS[painters.annotations > 0 ? 1 : 0][painters.formFields > 0 ? 1 : 0];
|
|
4297
|
+
if (next === painted) return;
|
|
4298
|
+
painted = next;
|
|
4299
|
+
if (notifying) return;
|
|
4300
|
+
notifying = true;
|
|
4301
|
+
queueMicrotask(() => {
|
|
4302
|
+
notifying = false;
|
|
4303
|
+
listeners.forEach((listener) => listener());
|
|
4304
|
+
});
|
|
4305
|
+
};
|
|
4306
|
+
return {
|
|
4307
|
+
paint(part) {
|
|
4308
|
+
painters[part] += 1;
|
|
4309
|
+
update();
|
|
4310
|
+
let released = false;
|
|
4311
|
+
return () => {
|
|
4312
|
+
if (released) return;
|
|
4313
|
+
released = true;
|
|
4314
|
+
painters[part] -= 1;
|
|
4315
|
+
update();
|
|
4316
|
+
};
|
|
4317
|
+
},
|
|
4318
|
+
painted: () => painted,
|
|
4319
|
+
subscribe(listener) {
|
|
4320
|
+
listeners.add(listener);
|
|
4321
|
+
return () => listeners.delete(listener);
|
|
4322
|
+
}
|
|
4323
|
+
};
|
|
4324
|
+
}
|
|
4325
|
+
/**
|
|
4326
|
+
* What the page's picture draws, as the render plugin's layer options: a part
|
|
4327
|
+
* the props name is drawn or left out as they say; any other part is drawn
|
|
4328
|
+
* when no layer on the page paints it and the user may read it. Both are
|
|
4329
|
+
* always given, so a picture that leaves the annotations to a layer still
|
|
4330
|
+
* draws the form fields.
|
|
4331
|
+
*/
|
|
4332
|
+
function pictureLayerOptionsOf(painted, props, may) {
|
|
4333
|
+
return {
|
|
4334
|
+
includeAnnotations: props.annotations ?? (!painted.annotations && may.annotations),
|
|
4335
|
+
includeFormFields: props.formFields ?? (!painted.formFields && may.formFields)
|
|
4336
|
+
};
|
|
4337
|
+
}
|
|
4338
|
+
/**
|
|
4339
|
+
* The parts a picture draws that a layer on the page paints too, because the
|
|
4340
|
+
* props asked for them: drawn twice.
|
|
4341
|
+
*/
|
|
4342
|
+
function partsDrawnTwice(painted, props) {
|
|
4343
|
+
const twice = [];
|
|
4344
|
+
if (props.annotations === true && painted.annotations) twice.push("annotations");
|
|
4345
|
+
if (props.formFields === true && painted.formFields) twice.push("formFields");
|
|
4346
|
+
return twice;
|
|
4347
|
+
}
|
|
4348
|
+
//#endregion
|
|
4349
|
+
//#region src/page-surface.ts
|
|
4350
|
+
/**
|
|
4351
|
+
* Lay out a page surface. `footprint` is where the page's footprint sits in the surface's
|
|
4352
|
+
* parent (a Stage page's device-snapped `screenX`/`screenY`); the outer box sits one band further
|
|
4353
|
+
* out, so the content keeps its place. Without it the outer box is at 0, 0 (a `PageView`, placed
|
|
4354
|
+
* by the page around it).
|
|
4355
|
+
*/
|
|
4356
|
+
function pageSurfaceLayout(transform, frame, footprint) {
|
|
4357
|
+
const { viewWidth, viewHeight, contentWidth, contentHeight, rotation } = transform;
|
|
4358
|
+
return {
|
|
4359
|
+
outer: {
|
|
4360
|
+
left: footprint ? footprint.x - frame.left : 0,
|
|
4361
|
+
top: footprint ? footprint.y - frame.top : 0,
|
|
4362
|
+
width: viewWidth + frame.left + frame.right,
|
|
4363
|
+
height: viewHeight + frame.top + frame.bottom
|
|
4364
|
+
},
|
|
4365
|
+
shadow: {
|
|
4366
|
+
left: frame.left,
|
|
4367
|
+
top: frame.top,
|
|
4368
|
+
width: viewWidth,
|
|
4369
|
+
height: viewHeight
|
|
4370
|
+
},
|
|
4371
|
+
content: {
|
|
4372
|
+
left: frame.left + (viewWidth - contentWidth) / 2,
|
|
4373
|
+
top: frame.top + (viewHeight - contentHeight) / 2,
|
|
4374
|
+
width: contentWidth,
|
|
4375
|
+
height: contentHeight
|
|
4376
|
+
},
|
|
4377
|
+
turn: rotation ? `rotate(${rotation}deg)` : null
|
|
4378
|
+
};
|
|
4379
|
+
}
|
|
4380
|
+
/**
|
|
4381
|
+
* What a Stage page's view wants rendered, read live from the Stage when the render layer asks:
|
|
4382
|
+
* the page's visible part at its device width, or a zero box (want nothing) while the page is
|
|
4383
|
+
* off screen, at `offScreenDeviceWidth`. Visibility is the Stage's data, so no adapter derives
|
|
4384
|
+
* camera math or keeps a copy of it.
|
|
4385
|
+
*/
|
|
4386
|
+
function stagePageDemand(stage, pageObjectNumber, offScreenDeviceWidth) {
|
|
4387
|
+
const visible = stage.listVisiblePages().find((visiblePage) => visiblePage.ref.objectNumber === pageObjectNumber);
|
|
4388
|
+
return visible ? {
|
|
4389
|
+
desiredDeviceWidth: visible.transform.deviceWidth,
|
|
4390
|
+
visibleRect: visible.visibleRect
|
|
4391
|
+
} : {
|
|
4392
|
+
desiredDeviceWidth: offScreenDeviceWidth,
|
|
4393
|
+
visibleRect: {
|
|
4394
|
+
x: 0,
|
|
4395
|
+
y: 0,
|
|
4396
|
+
width: 0,
|
|
4397
|
+
height: 0
|
|
4398
|
+
}
|
|
4399
|
+
};
|
|
4400
|
+
}
|
|
4401
|
+
/** One CSS pixel per 1/96 inch, one PDF point per 1/72 inch. */
|
|
4402
|
+
const CSS_PIXELS_PER_POINT = 96 / 72;
|
|
4403
|
+
/**
|
|
4404
|
+
* What a `PageView` builds its page transform from: no camera, so the scale (view pixels per
|
|
4405
|
+
* point) is `width` over the page's width. The base scale is physical 100% on the web, a point
|
|
4406
|
+
* at 96/72 CSS pixels times the page's /UserUnit, so `transform.zoom` still means something: a
|
|
4407
|
+
* thumbnail-sized page reads as zoomed out. Without a page yet, a 1 × 1 placeholder.
|
|
4408
|
+
*/
|
|
4409
|
+
function pageViewTransformInput(page, width, dpr) {
|
|
4410
|
+
return {
|
|
4411
|
+
pageSize: page ? {
|
|
4412
|
+
width: page.size.width,
|
|
4413
|
+
height: page.size.height
|
|
4414
|
+
} : {
|
|
4415
|
+
width: 1,
|
|
4416
|
+
height: 1
|
|
4417
|
+
},
|
|
4418
|
+
rotation: page?.rotation ?? 0,
|
|
4419
|
+
scale: page ? width / page.size.width : 1,
|
|
4420
|
+
baseScale: CSS_PIXELS_PER_POINT * (page?.userUnit ?? 1),
|
|
4421
|
+
dpr
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
4424
|
+
//#endregion
|
|
4425
|
+
//#region src/page-pointer.ts
|
|
4426
|
+
/**
|
|
4427
|
+
* A multi-click counter. `pointerdown.detail` is 0 or 1 in several browsers, so clicks are
|
|
4428
|
+
* counted here from timing and distance: the standard double and triple click. Input is
|
|
4429
|
+
* normalized in the adapter; the hub and its handlers stay pure.
|
|
4430
|
+
*/
|
|
4431
|
+
function createClickCounter(maxGapMs = 400, maxDistancePx = 6) {
|
|
4432
|
+
let last = 0;
|
|
4433
|
+
let lastX = 0;
|
|
4434
|
+
let lastY = 0;
|
|
4435
|
+
let count = 0;
|
|
4436
|
+
return (now, x, y) => {
|
|
4437
|
+
count = now - last <= maxGapMs && Math.hypot(x - lastX, y - lastY) <= maxDistancePx ? count + 1 : 1;
|
|
4438
|
+
last = now;
|
|
4439
|
+
lastX = x;
|
|
4440
|
+
lastY = y;
|
|
4441
|
+
return count;
|
|
4442
|
+
};
|
|
4443
|
+
}
|
|
4444
|
+
/**
|
|
4445
|
+
* Listen to the pointer on `element` for the page `page()` gives when an event arrives (a zoom
|
|
4446
|
+
* changes it, and a drag carries on through it), and dispatch every event to `hub`. A press
|
|
4447
|
+
* starts on the element; while it lasts the window listens, so a drag goes on outside the page.
|
|
4448
|
+
* Returns the function that stops listening.
|
|
4449
|
+
*/
|
|
4450
|
+
function attachPagePointer(element, hub, page) {
|
|
4451
|
+
const clicks = createClickCounter();
|
|
4452
|
+
const sample = (phase, event, clickCount = 1) => {
|
|
4453
|
+
const current = page();
|
|
4454
|
+
const rect = element.getBoundingClientRect();
|
|
4455
|
+
return {
|
|
4456
|
+
phase,
|
|
4457
|
+
viewport: {
|
|
4458
|
+
x: event.clientX - rect.left,
|
|
4459
|
+
y: event.clientY - rect.top
|
|
4460
|
+
},
|
|
4461
|
+
page: {
|
|
4462
|
+
ref: current.ref,
|
|
4463
|
+
point: current.toPagePoint(event.clientX, event.clientY),
|
|
4464
|
+
scale: current.transform.viewScale,
|
|
4465
|
+
rotation: current.transform.rotation,
|
|
4466
|
+
zoom: current.transform.zoom
|
|
4467
|
+
},
|
|
4468
|
+
project: (target) => target.kind === current.ref.kind && target.objectNumber === current.ref.objectNumber ? current.toPagePoint(event.clientX, event.clientY) : null,
|
|
4469
|
+
modifiers: {
|
|
4470
|
+
shift: event.shiftKey,
|
|
4471
|
+
alt: event.altKey,
|
|
4472
|
+
ctrl: event.ctrlKey,
|
|
4473
|
+
meta: event.metaKey
|
|
4474
|
+
},
|
|
4475
|
+
clickCount,
|
|
4476
|
+
pointerType: event.pointerType || "mouse"
|
|
4477
|
+
};
|
|
4478
|
+
};
|
|
4479
|
+
let dragging = false;
|
|
4480
|
+
const down = (event) => {
|
|
4481
|
+
if (event.button !== 0) return;
|
|
4482
|
+
dragging = true;
|
|
4483
|
+
hub.dispatchPointer(sample("down", event, clicks(Date.now(), event.clientX, event.clientY)));
|
|
4484
|
+
};
|
|
4485
|
+
const hover = (event) => {
|
|
4486
|
+
if (dragging) return;
|
|
4487
|
+
hub.dispatchPointer(sample("move", event));
|
|
4488
|
+
};
|
|
4489
|
+
const drag = (event) => {
|
|
4490
|
+
if (!dragging) return;
|
|
4491
|
+
hub.dispatchPointer(sample("move", event));
|
|
4492
|
+
};
|
|
4493
|
+
const up = (event) => {
|
|
4494
|
+
if (!dragging) return;
|
|
4495
|
+
dragging = false;
|
|
4496
|
+
hub.dispatchPointer(sample("up", event));
|
|
4497
|
+
};
|
|
4498
|
+
element.addEventListener("pointerdown", down);
|
|
4499
|
+
element.addEventListener("pointermove", hover);
|
|
4500
|
+
window.addEventListener("pointermove", drag);
|
|
4501
|
+
window.addEventListener("pointerup", up);
|
|
4502
|
+
return () => {
|
|
4503
|
+
element.removeEventListener("pointerdown", down);
|
|
4504
|
+
element.removeEventListener("pointermove", hover);
|
|
4505
|
+
window.removeEventListener("pointermove", drag);
|
|
4506
|
+
window.removeEventListener("pointerup", up);
|
|
4507
|
+
};
|
|
4508
|
+
}
|
|
4509
|
+
//#endregion
|
|
4510
|
+
//#region src/scrollbar.ts
|
|
4511
|
+
/**
|
|
4512
|
+
* The thumb from the metrics and the track's length: one formula for drawing, dragging and
|
|
4513
|
+
* paging, so they never disagree. The thumb never gets shorter than `minThumbSize`, and its
|
|
4514
|
+
* position stays proportional over the travel that leaves.
|
|
4515
|
+
*/
|
|
4516
|
+
function scrollbarLayout(metrics, vertical, trackPx, minThumbSize) {
|
|
4517
|
+
const client = vertical ? metrics.clientHeight : metrics.clientWidth;
|
|
4518
|
+
const total = vertical ? metrics.scrollHeight : metrics.scrollWidth;
|
|
4519
|
+
const offset = vertical ? metrics.scrollTop : metrics.scrollLeft;
|
|
4520
|
+
const maxOffset = Math.max(0, total - client);
|
|
4521
|
+
const thumbLength = Math.min(trackPx, Math.max(minThumbSize, total > 0 ? client / total * trackPx : 0));
|
|
4522
|
+
const travel = Math.max(0, trackPx - thumbLength);
|
|
4523
|
+
return {
|
|
4524
|
+
client,
|
|
4525
|
+
offset,
|
|
4526
|
+
maxOffset,
|
|
4527
|
+
thumbLength,
|
|
4528
|
+
travel,
|
|
4529
|
+
thumbPosition: maxOffset > 0 ? offset / maxOffset * travel : 0
|
|
4530
|
+
};
|
|
4531
|
+
}
|
|
4532
|
+
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
4533
|
+
/**
|
|
4534
|
+
* Pointer capture where the browser allows it: a pointer already released (a pen or touch race,
|
|
4535
|
+
* a synthetic event in a test) throws, and the press must still work, only without capture.
|
|
4536
|
+
*/
|
|
4537
|
+
const capturePointer = (element, pointerId) => {
|
|
4538
|
+
try {
|
|
4539
|
+
element.setPointerCapture(pointerId);
|
|
4540
|
+
} catch {}
|
|
4541
|
+
};
|
|
4542
|
+
/** The pointer's position along the track, in pixels from its start. */
|
|
4543
|
+
const positionOnTrack = (event, track) => {
|
|
4544
|
+
const rect = track.element.getBoundingClientRect();
|
|
4545
|
+
return track.vertical ? event.clientY - rect.top : event.clientX - rect.left;
|
|
4546
|
+
};
|
|
4547
|
+
/**
|
|
4548
|
+
* One scrollbar's press handling. `onDragging` hears when a drag starts and ends, for the bar's
|
|
4549
|
+
* `data-dragging` and to keep it shown.
|
|
4550
|
+
*/
|
|
4551
|
+
function createScrollbarPresses(onDragging) {
|
|
4552
|
+
let drag = null;
|
|
4553
|
+
let paging = null;
|
|
4554
|
+
let lastPointer = 0;
|
|
4555
|
+
const stopPaging = () => {
|
|
4556
|
+
if (!paging) return;
|
|
4557
|
+
clearTimeout(paging.timer);
|
|
4558
|
+
clearInterval(paging.timer);
|
|
4559
|
+
paging = null;
|
|
4560
|
+
};
|
|
4561
|
+
const beginDrag = (grab, applied, layout) => {
|
|
4562
|
+
drag = {
|
|
4563
|
+
grab,
|
|
4564
|
+
applied,
|
|
4565
|
+
max: layout.maxOffset,
|
|
4566
|
+
travel: layout.travel
|
|
4567
|
+
};
|
|
4568
|
+
onDragging(true);
|
|
4569
|
+
};
|
|
4570
|
+
return {
|
|
4571
|
+
pressThumb(event, track) {
|
|
4572
|
+
if (event.button !== 0) return;
|
|
4573
|
+
event.preventDefault();
|
|
4574
|
+
event.stopPropagation();
|
|
4575
|
+
capturePointer(event.target, event.pointerId);
|
|
4576
|
+
const layout = track.layout;
|
|
4577
|
+
beginDrag(positionOnTrack(event, track) - layout.thumbPosition, layout.offset, layout);
|
|
4578
|
+
},
|
|
4579
|
+
pressTrack(event, track, mode) {
|
|
4580
|
+
if (event.button !== 0 || event.target !== event.currentTarget) return;
|
|
4581
|
+
event.preventDefault();
|
|
4582
|
+
capturePointer(event.currentTarget, event.pointerId);
|
|
4583
|
+
const position = lastPointer = positionOnTrack(event, track);
|
|
4584
|
+
const { layout, stage, vertical } = track;
|
|
4585
|
+
if (mode === "jump") {
|
|
4586
|
+
const wanted = layout.travel > 0 ? clamp01((position - layout.thumbLength / 2) / layout.travel) * layout.maxOffset : 0;
|
|
4587
|
+
stage.scrollTo(vertical ? { top: wanted } : { left: wanted });
|
|
4588
|
+
beginDrag(layout.thumbLength / 2, wanted, layout);
|
|
4589
|
+
return;
|
|
4590
|
+
}
|
|
4591
|
+
const direction = position < layout.thumbPosition ? -1 : 1;
|
|
4592
|
+
const step = () => {
|
|
4593
|
+
const live = track.liveLayout();
|
|
4594
|
+
if (direction === 1 ? lastPointer <= live.thumbPosition + live.thumbLength : lastPointer >= live.thumbPosition) {
|
|
4595
|
+
stopPaging();
|
|
4596
|
+
return;
|
|
4597
|
+
}
|
|
4598
|
+
const distance = direction * live.client * .9;
|
|
4599
|
+
stage.scrollBy(vertical ? { top: distance } : { left: distance });
|
|
4600
|
+
};
|
|
4601
|
+
step();
|
|
4602
|
+
paging = { timer: window.setTimeout(() => {
|
|
4603
|
+
if (paging) paging.timer = window.setInterval(step, 80);
|
|
4604
|
+
}, 350) };
|
|
4605
|
+
},
|
|
4606
|
+
move(event, track) {
|
|
4607
|
+
if (drag) {
|
|
4608
|
+
if (drag.travel <= 0) return;
|
|
4609
|
+
const wanted = clamp01((positionOnTrack(event, track) - drag.grab) / drag.travel) * drag.max;
|
|
4610
|
+
const delta = wanted - drag.applied;
|
|
4611
|
+
if (delta) {
|
|
4612
|
+
track.stage.panBy(track.vertical ? 0 : -delta, track.vertical ? -delta : 0);
|
|
4613
|
+
drag.applied = wanted;
|
|
4614
|
+
}
|
|
4615
|
+
} else if (paging) lastPointer = positionOnTrack(event, track);
|
|
4616
|
+
},
|
|
4617
|
+
release() {
|
|
4618
|
+
drag = null;
|
|
4619
|
+
stopPaging();
|
|
4620
|
+
onDragging(false);
|
|
4621
|
+
}
|
|
4622
|
+
};
|
|
4623
|
+
}
|
|
4624
|
+
//#endregion
|
|
4625
|
+
//#region src/toolbar-measure.ts
|
|
4626
|
+
/** The keys the measurement layer reports lengths under, the ones the metrics look up. */
|
|
4627
|
+
const toolbarMeasureKey = {
|
|
4628
|
+
/** A unit (`NormalizedUnit.key`) at one variant. */
|
|
4629
|
+
unit: (unitKey, variant) => `u:${unitKey}@${variant}`,
|
|
4630
|
+
/** A group in its collapsed form. */
|
|
4631
|
+
group: (groupId) => `g:${groupId}`,
|
|
4632
|
+
/** A shed group's disclosure trigger, measured with the whole group behind it. */
|
|
4633
|
+
groupTrigger: (groupId) => `gt:${groupId}`,
|
|
4634
|
+
/** The "More" button. */
|
|
4635
|
+
overflowTrigger: "t:"
|
|
4636
|
+
};
|
|
4637
|
+
function createToolbarLengths() {
|
|
4638
|
+
const byOrientation = {
|
|
4639
|
+
horizontal: /* @__PURE__ */ new Map(),
|
|
4640
|
+
vertical: /* @__PURE__ */ new Map()
|
|
4641
|
+
};
|
|
4642
|
+
return {
|
|
4643
|
+
report(orientation, key, length) {
|
|
4644
|
+
const lengths = byOrientation[orientation];
|
|
4645
|
+
const previous = lengths.get(key);
|
|
4646
|
+
if (previous !== void 0 && Math.abs(previous - length) <= .5) return false;
|
|
4647
|
+
lengths.set(key, length);
|
|
4648
|
+
return true;
|
|
4649
|
+
},
|
|
4650
|
+
metrics(orientation, gap, separatorWidth) {
|
|
4651
|
+
const lengths = byOrientation[orientation];
|
|
4652
|
+
return {
|
|
4653
|
+
unit: (key, variant) => lengths.get(toolbarMeasureKey.unit(key, variant)),
|
|
4654
|
+
groupCollapsed: (groupId) => lengths.get(toolbarMeasureKey.group(groupId)),
|
|
4655
|
+
groupTrigger: (groupId) => lengths.get(toolbarMeasureKey.groupTrigger(groupId)),
|
|
4656
|
+
overflowTrigger: lengths.get(toolbarMeasureKey.overflowTrigger) ?? 32,
|
|
4657
|
+
gap,
|
|
4658
|
+
separator: separatorWidth + gap
|
|
4659
|
+
};
|
|
4660
|
+
}
|
|
4661
|
+
};
|
|
4662
|
+
}
|
|
4663
|
+
/** Report one side of an element's border box now and whenever it resizes. */
|
|
4664
|
+
function observeBorderBox(element, side, onLength) {
|
|
4665
|
+
const report = () => onLength(element.getBoundingClientRect()[side]);
|
|
4666
|
+
report();
|
|
4667
|
+
const observer = new ResizeObserver(report);
|
|
4668
|
+
observer.observe(element);
|
|
4669
|
+
return () => observer.disconnect();
|
|
4670
|
+
}
|
|
4671
|
+
/**
|
|
4672
|
+
* Report an element's width (its border box) now and whenever it resizes. Put
|
|
4673
|
+
* it on every element of a horizontal toolbar's measurement layer, and on a
|
|
4674
|
+
* custom slot's `<slot>` socket, which is measured live where it renders.
|
|
4675
|
+
* Returns the detach.
|
|
4676
|
+
*/
|
|
4677
|
+
function observeWidth(element, onWidth) {
|
|
4678
|
+
return observeBorderBox(element, "width", onWidth);
|
|
4679
|
+
}
|
|
4680
|
+
/** {@link observeWidth} for a vertical toolbar: an element's border-box height. */
|
|
4681
|
+
function observeHeight(element, onHeight) {
|
|
4682
|
+
return observeBorderBox(element, "height", onHeight);
|
|
4683
|
+
}
|
|
4684
|
+
/** An element's length along a toolbar: {@link observeWidth} or {@link observeHeight}. */
|
|
4685
|
+
function observeLength(element, orientation, onLength) {
|
|
4686
|
+
return observeBorderBox(element, orientation === "vertical" ? "height" : "width", onLength);
|
|
4687
|
+
}
|
|
4688
|
+
/** Report one side of an element's content box now, then on changes over half a pixel. */
|
|
4689
|
+
function observeContentBox(element, side, onLength) {
|
|
4690
|
+
let last = side === "width" ? element.clientWidth : element.clientHeight;
|
|
4691
|
+
onLength(last);
|
|
4692
|
+
const observer = new ResizeObserver((entries) => {
|
|
4693
|
+
const length = entries[entries.length - 1]?.contentRect[side];
|
|
4694
|
+
if (length === void 0 || Math.abs(last - length) <= .5) return;
|
|
4695
|
+
last = length;
|
|
4696
|
+
onLength(length);
|
|
4697
|
+
});
|
|
4698
|
+
observer.observe(element);
|
|
4699
|
+
return () => observer.disconnect();
|
|
4700
|
+
}
|
|
4701
|
+
/**
|
|
4702
|
+
* Report the width a toolbar has to fill (its content box) now and whenever
|
|
4703
|
+
* it changes by more than half a pixel. Returns the detach.
|
|
4704
|
+
*/
|
|
4705
|
+
function observeContentWidth(element, onWidth) {
|
|
4706
|
+
return observeContentBox(element, "width", onWidth);
|
|
4707
|
+
}
|
|
4708
|
+
/**
|
|
4709
|
+
* The height a vertical toolbar has to fill, like {@link observeContentWidth}.
|
|
4710
|
+
* The toolbar fits the height it's given, so its container needs one.
|
|
4711
|
+
*/
|
|
4712
|
+
function observeContentHeight(element, onHeight) {
|
|
4713
|
+
return observeContentBox(element, "height", onHeight);
|
|
4714
|
+
}
|
|
4715
|
+
/** The room along a toolbar: {@link observeContentWidth} or {@link observeContentHeight}. */
|
|
4716
|
+
function observeContentLength(element, orientation, onLength) {
|
|
4717
|
+
return observeContentBox(element, orientation === "vertical" ? "height" : "width", onLength);
|
|
4718
|
+
}
|
|
4719
|
+
//#endregion
|
|
4720
|
+
//#region src/orientation.ts
|
|
4721
|
+
/** The writing direction an element lays out in: its computed CSS `direction`. */
|
|
4722
|
+
function directionOf(element) {
|
|
4723
|
+
return element.ownerDocument.defaultView?.getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr";
|
|
4724
|
+
}
|
|
4725
|
+
//#endregion
|
|
4726
|
+
//#region src/roving-focus.ts
|
|
4727
|
+
/**
|
|
4728
|
+
* One tab stop for a row of controls, and arrow keys along it (the ARIA
|
|
4729
|
+
* roving tabindex, as toolbars and tab lists use it): Tab enters the row at
|
|
4730
|
+
* the control that had focus last, else its first, and the arrows, Home and
|
|
4731
|
+
* End move between its controls. The arrows follow the row's orientation, Left and Right swap in
|
|
4732
|
+
* right-to-left text, a control that can't be used now is passed over, and a
|
|
4733
|
+
* control that uses an arrow key itself (a text field, a select) keeps it.
|
|
4734
|
+
*/
|
|
4735
|
+
/** The move `key` asks for in a row running `orientation`, or null for a key the row leaves alone. */
|
|
4736
|
+
function rovingMoveOf(key, orientation, direction) {
|
|
4737
|
+
if (key === "Home") return "first";
|
|
4738
|
+
if (key === "End") return "last";
|
|
4739
|
+
if (orientation === "vertical") {
|
|
4740
|
+
if (key === "ArrowDown") return "next";
|
|
4741
|
+
if (key === "ArrowUp") return "previous";
|
|
4742
|
+
return null;
|
|
4743
|
+
}
|
|
4744
|
+
const forward = direction === "rtl" ? "ArrowLeft" : "ArrowRight";
|
|
4745
|
+
const backward = direction === "rtl" ? "ArrowRight" : "ArrowLeft";
|
|
4746
|
+
if (key === forward) return "next";
|
|
4747
|
+
if (key === backward) return "previous";
|
|
4748
|
+
return null;
|
|
4749
|
+
}
|
|
4750
|
+
/**
|
|
4751
|
+
* The index `move` lands on among `count` stops, from `current` (-1 for
|
|
4752
|
+
* none), passing over the stops `available` rejects. With `loop`, next and
|
|
4753
|
+
* previous wrap around the ends; without it they stop there. -1 when there is
|
|
4754
|
+
* nowhere to go.
|
|
4755
|
+
*/
|
|
4756
|
+
function rovingTargetOf(move, current, count, available, loop) {
|
|
4757
|
+
if (move === "first" || move === "last") {
|
|
4758
|
+
const step = move === "first" ? 1 : -1;
|
|
4759
|
+
for (let i = move === "first" ? 0 : count - 1; i >= 0 && i < count; i += step) if (available(i)) return i;
|
|
4760
|
+
return -1;
|
|
4761
|
+
}
|
|
4762
|
+
const step = move === "next" ? 1 : -1;
|
|
4763
|
+
for (let taken = 1; taken <= count; taken += 1) {
|
|
4764
|
+
let index = current + step * taken;
|
|
4765
|
+
if (index < 0 || index >= count) {
|
|
4766
|
+
if (!loop) return -1;
|
|
4767
|
+
index = (index % count + count) % count;
|
|
4768
|
+
}
|
|
4769
|
+
if (index === current) return -1;
|
|
4770
|
+
if (available(index)) return index;
|
|
4771
|
+
}
|
|
4772
|
+
return -1;
|
|
4773
|
+
}
|
|
4774
|
+
/**
|
|
4775
|
+
* Make the stops of `container` one tab stop with arrow keys between them.
|
|
4776
|
+
* Keys count only while a stop itself has focus, so keys inside a menu or
|
|
4777
|
+
* another control the row holds are theirs; a key with Alt, Control, Meta or
|
|
4778
|
+
* Shift, or one a handler already took (`defaultPrevented`), is left alone.
|
|
4779
|
+
*/
|
|
4780
|
+
function attachRovingFocus(container, options) {
|
|
4781
|
+
const direction = options.direction ?? (() => directionOf(container));
|
|
4782
|
+
const loop = options.loop ?? true;
|
|
4783
|
+
/**
|
|
4784
|
+
* The stop that had focus last. Until one has, the tab stop follows the
|
|
4785
|
+
* first usable stop, wherever the row's first render put it.
|
|
4786
|
+
*/
|
|
4787
|
+
let used = null;
|
|
4788
|
+
const giveTabStop = (stops) => {
|
|
4789
|
+
const tabStop = used !== null && stops.includes(used) && !isUnavailable(used) ? used : stops.find((stop) => !isUnavailable(stop)) ?? null;
|
|
4790
|
+
for (const stop of stops) {
|
|
4791
|
+
const tabIndex = stop === tabStop ? 0 : -1;
|
|
4792
|
+
if (stop.tabIndex !== tabIndex) stop.tabIndex = tabIndex;
|
|
4793
|
+
}
|
|
4794
|
+
};
|
|
4795
|
+
const refresh = () => giveTabStop(options.stops());
|
|
4796
|
+
const onKeyDown = (event) => {
|
|
4797
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
4798
|
+
const stops = options.stops();
|
|
4799
|
+
const target = event.target;
|
|
4800
|
+
const from = stops.indexOf(target);
|
|
4801
|
+
if (from < 0 || usesKey(target, event.key)) return;
|
|
4802
|
+
const move = rovingMoveOf(event.key, options.orientation, direction());
|
|
4803
|
+
if (!move) return;
|
|
4804
|
+
const to = rovingTargetOf(move, from, stops.length, (index) => !isUnavailable(stops[index]), loop);
|
|
4805
|
+
if (to < 0) return;
|
|
4806
|
+
event.preventDefault();
|
|
4807
|
+
const next = stops[to];
|
|
4808
|
+
used = next;
|
|
4809
|
+
giveTabStop(stops);
|
|
4810
|
+
next.focus();
|
|
4811
|
+
};
|
|
4812
|
+
const onFocusIn = (event) => {
|
|
4813
|
+
const stops = options.stops();
|
|
4814
|
+
const target = event.target;
|
|
4815
|
+
if (!stops.includes(target)) return;
|
|
4816
|
+
used = target;
|
|
4817
|
+
giveTabStop(stops);
|
|
4818
|
+
};
|
|
4819
|
+
container.addEventListener("keydown", onKeyDown);
|
|
4820
|
+
container.addEventListener("focusin", onFocusIn);
|
|
4821
|
+
refresh();
|
|
4822
|
+
return {
|
|
4823
|
+
refresh,
|
|
4824
|
+
detach() {
|
|
4825
|
+
container.removeEventListener("keydown", onKeyDown);
|
|
4826
|
+
container.removeEventListener("focusin", onFocusIn);
|
|
4827
|
+
}
|
|
4828
|
+
};
|
|
4829
|
+
}
|
|
4830
|
+
//#endregion
|
|
4831
|
+
//#region src/toolbar-focus.ts
|
|
4832
|
+
/**
|
|
4833
|
+
* A toolbar's keyboard: one tab stop, and arrow keys along the bar
|
|
4834
|
+
* ({@link attachRovingFocus}). Each item of the live row (a command, your own
|
|
4835
|
+
* item, a folded group, the "More" button) sits in an element marked with
|
|
4836
|
+
* {@link TOOLBAR_ITEM_ATTRIBUTE}, and the first element in it that takes focus
|
|
4837
|
+
* is the item's stop: the rest of a custom item's controls are its own.
|
|
4838
|
+
*/
|
|
4839
|
+
/** The attribute on the element around each item of a toolbar's live row. */
|
|
4840
|
+
const TOOLBAR_ITEM_ATTRIBUTE = "data-epdf-toolbar-item";
|
|
4841
|
+
/**
|
|
4842
|
+
* A toolbar's stops: the first element of each item that takes focus, in
|
|
4843
|
+
* order. A menu an item holds open is not part of its stop.
|
|
4844
|
+
*/
|
|
4845
|
+
function toolbarStopsOf(toolbar) {
|
|
4846
|
+
const stops = [];
|
|
4847
|
+
for (const item of toolbar.querySelectorAll(`[${TOOLBAR_ITEM_ATTRIBUTE}]`)) {
|
|
4848
|
+
const stop = focusablesIn(item).find((element) => {
|
|
4849
|
+
const menu = element.closest("[role=\"menu\"]");
|
|
4850
|
+
return menu === null || !item.contains(menu);
|
|
4851
|
+
});
|
|
4852
|
+
if (stop) stops.push(stop);
|
|
4853
|
+
}
|
|
4854
|
+
return stops;
|
|
4855
|
+
}
|
|
4856
|
+
/** Arrow keys between a toolbar's items, along its orientation; `refresh()` after each render. */
|
|
4857
|
+
function attachToolbarFocus(toolbar, orientation) {
|
|
4858
|
+
return attachRovingFocus(toolbar, {
|
|
4859
|
+
orientation,
|
|
4860
|
+
stops: () => toolbarStopsOf(toolbar)
|
|
4861
|
+
});
|
|
4862
|
+
}
|
|
4863
|
+
//#endregion
|
|
4864
|
+
//#region src/menu-keys.ts
|
|
4865
|
+
/**
|
|
4866
|
+
* The keyboard of an open menu (the ARIA menu button pattern). The menu
|
|
4867
|
+
* takes focus on its first item; Up, Down, Home and End move between items,
|
|
4868
|
+
* passing over the ones that can't be used now; typing jumps to the next item
|
|
4869
|
+
* that starts with what was typed; Enter and Space pick; Escape closes the
|
|
4870
|
+
* menu and gives focus back to its button, and Tab closes it and moves on
|
|
4871
|
+
* from that button. While the menu is open, its button says so with
|
|
4872
|
+
* `aria-expanded`.
|
|
4873
|
+
*/
|
|
4874
|
+
/** How long typed letters keep adding to one search, in milliseconds. */
|
|
4875
|
+
const TYPEAHEAD_RESET_MS = 500;
|
|
4876
|
+
/** The menu's items when no `items` is given: its menu-item roles, in order. */
|
|
4877
|
+
const MENU_ITEM_SELECTOR = "[role=\"menuitem\"], [role=\"menuitemradio\"], [role=\"menuitemcheckbox\"]";
|
|
4878
|
+
/**
|
|
4879
|
+
* The item typeahead lands on: the first one from `current` on, wrapping, that
|
|
4880
|
+
* is available and whose label starts with `query` (any case). A one-letter
|
|
4881
|
+
* query starts after `current`, so typing the same letter again moves on, and
|
|
4882
|
+
* a query of one letter repeated ("ppp") cycles like that letter. -1 when no
|
|
4883
|
+
* item matches.
|
|
4884
|
+
*/
|
|
4885
|
+
function typeaheadMatchOf(labels, current, query, available) {
|
|
4886
|
+
const typed = query.toLocaleLowerCase();
|
|
4887
|
+
const search = typed.length > 1 && [...typed].every((letter) => letter === typed[0]) ? typed[0] : typed;
|
|
4888
|
+
const offset = search.length === 1 ? 1 : 0;
|
|
4889
|
+
const count = labels.length;
|
|
4890
|
+
for (let taken = 0; taken < count; taken += 1) {
|
|
4891
|
+
const index = ((current + offset + taken) % count + count) % count;
|
|
4892
|
+
if (available(index) && labels[index].toLocaleLowerCase().startsWith(search)) return index;
|
|
4893
|
+
}
|
|
4894
|
+
return -1;
|
|
4895
|
+
}
|
|
4896
|
+
/**
|
|
4897
|
+
* The words typeahead matches an item by: its `aria-label`, else its text,
|
|
4898
|
+
* without the marks a row may start with (a bullet, a check, an icon glyph).
|
|
4899
|
+
*/
|
|
4900
|
+
function menuItemLabelOf(item) {
|
|
4901
|
+
return (item.getAttribute("aria-label") ?? item.textContent ?? "").trim().replace(/^[^\p{L}\p{N}]+/u, "");
|
|
4902
|
+
}
|
|
4903
|
+
/**
|
|
4904
|
+
* Drive an open `menu` from the keyboard until the returned detach runs.
|
|
4905
|
+
* Detaching (the menu closed) gives focus back to the trigger when focus was
|
|
4906
|
+
* in the menu or went nowhere, and never when the user moved it elsewhere.
|
|
4907
|
+
*/
|
|
4908
|
+
function attachMenuKeys(menu, options) {
|
|
4909
|
+
const { trigger, close } = options;
|
|
4910
|
+
const items = options.items ?? (() => [...menu.querySelectorAll(MENU_ITEM_SELECTOR)]);
|
|
4911
|
+
let typed = "";
|
|
4912
|
+
let typedAt = Number.NEGATIVE_INFINITY;
|
|
4913
|
+
const listed = () => {
|
|
4914
|
+
const list = items();
|
|
4915
|
+
for (const item of list) if (item.tabIndex !== -1) item.tabIndex = -1;
|
|
4916
|
+
return list;
|
|
4917
|
+
};
|
|
4918
|
+
const available = (list) => (index) => !isUnavailable(list[index]);
|
|
4919
|
+
const onKeyDown = (event) => {
|
|
4920
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
4921
|
+
const list = listed();
|
|
4922
|
+
const from = list.indexOf(event.target);
|
|
4923
|
+
if (event.key === "Escape") {
|
|
4924
|
+
event.preventDefault();
|
|
4925
|
+
event.stopPropagation();
|
|
4926
|
+
close();
|
|
4927
|
+
trigger?.focus();
|
|
4928
|
+
return;
|
|
4929
|
+
}
|
|
4930
|
+
if (event.key === "Tab") {
|
|
4931
|
+
close();
|
|
4932
|
+
trigger?.focus();
|
|
4933
|
+
return;
|
|
4934
|
+
}
|
|
4935
|
+
if ((event.key === "Enter" || event.key === " " && typed === "") && from >= 0) {
|
|
4936
|
+
event.preventDefault();
|
|
4937
|
+
if (!isUnavailable(list[from])) list[from].click();
|
|
4938
|
+
return;
|
|
4939
|
+
}
|
|
4940
|
+
const move = event.shiftKey ? null : rovingMoveOf(event.key, "vertical", "ltr");
|
|
4941
|
+
if (move) {
|
|
4942
|
+
event.preventDefault();
|
|
4943
|
+
const to = rovingTargetOf(move, from, list.length, available(list), true);
|
|
4944
|
+
if (to >= 0) list[to].focus();
|
|
4945
|
+
return;
|
|
4946
|
+
}
|
|
4947
|
+
if (event.key.length === 1) {
|
|
4948
|
+
if (event.timeStamp - typedAt > TYPEAHEAD_RESET_MS) typed = "";
|
|
4949
|
+
typed += event.key;
|
|
4950
|
+
typedAt = event.timeStamp;
|
|
4951
|
+
const to = typeaheadMatchOf(list.map(menuItemLabelOf), from, typed, available(list));
|
|
4952
|
+
if (to >= 0) {
|
|
4953
|
+
event.preventDefault();
|
|
4954
|
+
list[to].focus();
|
|
4955
|
+
}
|
|
4956
|
+
}
|
|
4957
|
+
};
|
|
4958
|
+
const onKeyUp = (event) => {
|
|
4959
|
+
if (event.key === " " && !event.defaultPrevented) event.preventDefault();
|
|
4960
|
+
};
|
|
4961
|
+
menu.addEventListener("keydown", onKeyDown);
|
|
4962
|
+
menu.addEventListener("keyup", onKeyUp);
|
|
4963
|
+
trigger?.setAttribute("aria-expanded", "true");
|
|
4964
|
+
const initial = options.initialFocus ?? "first";
|
|
4965
|
+
if (initial !== "none") {
|
|
4966
|
+
const list = listed();
|
|
4967
|
+
const to = rovingTargetOf(initial, -1, list.length, available(list), false);
|
|
4968
|
+
if (to >= 0) list[to].focus();
|
|
4969
|
+
}
|
|
4970
|
+
return () => {
|
|
4971
|
+
menu.removeEventListener("keydown", onKeyDown);
|
|
4972
|
+
menu.removeEventListener("keyup", onKeyUp);
|
|
4973
|
+
trigger?.setAttribute("aria-expanded", "false");
|
|
4974
|
+
const active = deepActiveElementOf(menu.ownerDocument);
|
|
4975
|
+
if (trigger && (active === null || menu.contains(active))) trigger.focus();
|
|
4976
|
+
};
|
|
4977
|
+
}
|
|
4978
|
+
//#endregion
|
|
4979
|
+
//#region src/focus-trap.ts
|
|
4980
|
+
/**
|
|
4981
|
+
* Keep keyboard focus inside a dialog or sheet while it's open. It takes
|
|
4982
|
+
* focus when the trap starts, unless focus is inside already (the control you
|
|
4983
|
+
* name, else its first one that Tab stops at, else the container itself); Tab
|
|
4984
|
+
* and Shift+Tab go round its controls without leaving it; focus that lands
|
|
4985
|
+
* outside comes back; and when the trap is released, focus returns to where it
|
|
4986
|
+
* was before. Traps nest: only the one started last acts, until it's released.
|
|
4987
|
+
*/
|
|
4988
|
+
/**
|
|
4989
|
+
* Move focus into `container` unless it's there already: to `initialFocus`, else its first
|
|
4990
|
+
* control Tab stops at, else the container itself. Focus already inside stays where it is, so a
|
|
4991
|
+
* control that took focus as it appeared (a search field asking for the cursor) keeps it.
|
|
4992
|
+
*/
|
|
4993
|
+
function moveFocusInto(container, initialFocus) {
|
|
4994
|
+
if (containsFocus(container)) return;
|
|
4995
|
+
if (initialFocus) {
|
|
4996
|
+
initialFocus.focus();
|
|
4997
|
+
return;
|
|
4998
|
+
}
|
|
4999
|
+
const first = tabbablesIn(container)[0];
|
|
5000
|
+
if (first) {
|
|
5001
|
+
first.focus();
|
|
5002
|
+
return;
|
|
5003
|
+
}
|
|
5004
|
+
if (!container.hasAttribute("tabindex")) container.tabIndex = -1;
|
|
5005
|
+
container.focus();
|
|
5006
|
+
}
|
|
5007
|
+
/** The traps holding focus now, the innermost last. */
|
|
5008
|
+
const activeTraps = [];
|
|
5009
|
+
/** Trap focus in `container` until the returned release runs. */
|
|
5010
|
+
function attachFocusTrap(container, options = {}) {
|
|
5011
|
+
const doc = container.ownerDocument;
|
|
5012
|
+
const before = deepActiveElementOf(doc);
|
|
5013
|
+
const isInnermost = () => activeTraps[activeTraps.length - 1] === container;
|
|
5014
|
+
/** The control that had focus last inside the trap, to go back to. */
|
|
5015
|
+
let last = null;
|
|
5016
|
+
const focusInside = (edge) => {
|
|
5017
|
+
const tabbables = tabbablesIn(container);
|
|
5018
|
+
const target = edge === "first" ? tabbables[0] : tabbables[tabbables.length - 1];
|
|
5019
|
+
if (target) {
|
|
5020
|
+
target.focus();
|
|
5021
|
+
return;
|
|
5022
|
+
}
|
|
5023
|
+
if (!container.hasAttribute("tabindex")) container.tabIndex = -1;
|
|
5024
|
+
container.focus();
|
|
5025
|
+
};
|
|
5026
|
+
const onKeyDown = (event) => {
|
|
5027
|
+
if (event.key !== "Tab" || event.defaultPrevented || !isInnermost()) return;
|
|
5028
|
+
const tabbables = tabbablesIn(container);
|
|
5029
|
+
const active = deepActiveElementOf(doc);
|
|
5030
|
+
const first = tabbables[0];
|
|
5031
|
+
const lastTabbable = tabbables[tabbables.length - 1];
|
|
5032
|
+
const atStart = active === container || active === first || !first;
|
|
5033
|
+
const atEnd = active === lastTabbable || !lastTabbable;
|
|
5034
|
+
if (event.shiftKey ? atStart : atEnd) {
|
|
5035
|
+
event.preventDefault();
|
|
5036
|
+
focusInside(event.shiftKey ? "last" : "first");
|
|
5037
|
+
}
|
|
5038
|
+
};
|
|
5039
|
+
const onFocusIn = (event) => {
|
|
5040
|
+
if (!isInnermost()) return;
|
|
5041
|
+
const path = event.composedPath();
|
|
5042
|
+
if (path.includes(container)) {
|
|
5043
|
+
last = path[0];
|
|
5044
|
+
return;
|
|
5045
|
+
}
|
|
5046
|
+
if (last?.isConnected && container.contains(last)) last.focus();
|
|
5047
|
+
else focusInside("first");
|
|
5048
|
+
};
|
|
5049
|
+
activeTraps.push(container);
|
|
5050
|
+
container.addEventListener("keydown", onKeyDown);
|
|
5051
|
+
doc.addEventListener("focusin", onFocusIn, true);
|
|
5052
|
+
moveFocusInto(container, options.initialFocus);
|
|
5053
|
+
if (last === null && containsFocus(container)) last = deepActiveElementOf(doc);
|
|
5054
|
+
return () => {
|
|
5055
|
+
const index = activeTraps.lastIndexOf(container);
|
|
5056
|
+
if (index >= 0) activeTraps.splice(index, 1);
|
|
5057
|
+
container.removeEventListener("keydown", onKeyDown);
|
|
5058
|
+
doc.removeEventListener("focusin", onFocusIn, true);
|
|
5059
|
+
if (options.returnFocus !== false && before?.isConnected) before.focus();
|
|
5060
|
+
};
|
|
5061
|
+
}
|
|
5062
|
+
//#endregion
|
|
5063
|
+
//#region src/surface-focus.ts
|
|
5064
|
+
/** Whether an element has text that `select()` selects. */
|
|
5065
|
+
const hasSelectableText = (element) => element.tagName === "INPUT" || element.tagName === "TEXTAREA";
|
|
5066
|
+
function createSurfaceFocusFollower() {
|
|
5067
|
+
let current = null;
|
|
5068
|
+
let taken = 0;
|
|
5069
|
+
return { follow(element, shell, request, options = {}) {
|
|
5070
|
+
if (shell !== current) {
|
|
5071
|
+
current = shell;
|
|
5072
|
+
taken = 0;
|
|
5073
|
+
}
|
|
5074
|
+
if (request === 0) {
|
|
5075
|
+
taken = 0;
|
|
5076
|
+
return;
|
|
5077
|
+
}
|
|
5078
|
+
if (request === taken || !element) return;
|
|
5079
|
+
taken = request;
|
|
5080
|
+
element.focus();
|
|
5081
|
+
if (options.select && hasSelectableText(element)) element.select();
|
|
5082
|
+
} };
|
|
5083
|
+
}
|
|
5084
|
+
//#endregion
|
|
5085
|
+
//#region src/standard-commands-platform.ts
|
|
5086
|
+
/**
|
|
5087
|
+
* The browser's part of the standard commands (`createStandardCommands` in
|
|
5088
|
+
* `@embedpdf/plugin-commands/standard`): copying the selected text, handing
|
|
5089
|
+
* the document to the user as a download, and the print dialog. Every
|
|
5090
|
+
* framework adapter builds its `standardCommands` from this one object.
|
|
5091
|
+
*/
|
|
5092
|
+
const standardCommandsBrowser = {
|
|
5093
|
+
copySelection,
|
|
5094
|
+
saveFile: (bytes, fileName) => saveFile(bytes, fileName, "application/pdf"),
|
|
5095
|
+
print: () => window.print()
|
|
5096
|
+
};
|
|
5097
|
+
//#endregion
|
|
5098
|
+
export { EPDF_VARIABLES, FORM_CONTROL_FILL, TOOLBAR_ITEM_ATTRIBUTE, anchoredViewOf, annotationChromePaint, annotationDrawingOf, applyFirstLineShift, attachFocusTrap, attachMenuKeys, attachPagePointer, attachRichTextEditor, attachRovingFocus, attachSelectionHandle, attachTextBoxEditor, attachToolbarFocus, bakedAppearanceOf, bindCommandShortcuts, bindPaintedImage, bindWidgetEvents, browserClock, canTakeFocus, chromeInPixels, clientPageProjector, computeReleaseVelocity, containsFocus, copySelection, createClickCounter, createClickDetector, createDefaultActionsUiAdapter, createFontMounts, createOptimisticSelection, createScrollbarPresses, createShownUrls, createStageGestureController, createStageSurface, createSurfaceFocusFollower, createTextBoxEditorFollower, createTextFieldEditor, createToolbarLengths, cssFontOf, cssText, deepActiveElementOf, directionOf, editingTextKeyOf, enrichCommentThreads, epdfThemeVariables, firstLineShiftFor, fitAnchoredRect, focusablesIn, fontProgramsOf, formColorsOf, formFocusRingStyleOf, frameInPixels, ghostOpacity, hoverLink, indexedDbByteStore, installFilePickerProvider, isMacPlatform, isModifiedClick, isTabbable, isUnavailable, isUprightQuad, isolatePointerDown, isolateWheel, layerTextBoxesOf, lineModelFor, linkActivateContextOf, linkAnchorOf, linkHrefOf, listBoxControlStyleOf, listBoxStyleOf, livePageContext, loadAppearanceUrls, loadObjectUrl, lookFrameOf, lookRendererFor, makePageContext, menuItemLabelOf, mixAccent, mountFontProgram, mountWebFont, moveFocusInto, navigableLinksOf, objectUrlOf, observeAnchoredFit, observeClientGeometry, observeContentHeight, observeContentLength, observeContentWidth, observeHeight, observeLength, observeOutsidePress, observeWidth, offsetOfPosition, openExternalUri, pageClientSpace, pageLayersOf, pageSurfaceLayout, pageViewTransformInput, paint, paintDefault, partsDrawnTwice, pickFile, pickImageFile, pickRequestedFile, pictureLayerOptionsOf, pictureOrPainting, positionAnchoredRect, positionOfOffset, pressToggle, projectAnchoredTarget, quadInPixels, rasterInFrame, rectInPixels, registerRendererBehaviors, renderRichText, rendererBehaviorId, rovingMoveOf, rovingTargetOf, sameAnchoredFit, sameAnnotationAnchor, sameCreationDraftAnchor, samePageBounds, sameRotationAnchor, sameSelectionAnchor, sameSelectionEndpoints, sanitizeExternalUri, saveFile, sceneViewBox, scrollbarLayout, searchHighlightsOf, sendLinkEvent, serializeRichText, showSelectedOptions, shownAppearance, stagePageDemand, stageViewProjector, standardCommandsBrowser, styleDeltaOf, svgCursor, svgPoints, svgShapesOf, tabbablesIn, textBoxEditorScaleOf, textBoxStyleOf, textFieldEditorStyleOf, textFieldStyleOf, textPlateInPixels, toolCursorsOf, toolbarMeasureKey, toolbarStopsOf, typeaheadMatchOf, usesKey, vibrationFeedback, webFontMetrics, wheelZoomFactor, widgetBoxStyleOf, wireSelectionClipboard, wkFeedback };
|
|
1911
5099
|
|
|
1912
5100
|
//# sourceMappingURL=index.js.map
|