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