@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 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. MUST be called
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 NO type filter — any file (attachments). */
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 a = document.createElement("a");
54
- a.href = url;
55
- a.download = name;
56
- a.style.display = "none";
57
- document.body.appendChild(a);
58
- a.click();
59
- a.remove();
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 or focused editable — e.g. the viewer shell's
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.onCommit(() => {
343
+ const offCommit = selection.onCommitted(() => {
94
344
  if (timer) clearTimeout(timer);
95
345
  timer = null;
96
346
  refresh();
97
347
  });
98
- const offChange = selection.onChange(() => {
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
- const onCopy = (e) => {
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
- e.clipboardData?.setData("text/plain", cached);
107
- e.preventDefault();
364
+ event.clipboardData?.setData("text/plain", cached);
365
+ event.preventDefault();
108
366
  };
109
- const onKeyDown = (e) => {
110
- if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "c" || e.shiftKey || e.altKey) return;
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.getSelection?.();
371
+ const dom = domSelectionOf(target);
113
372
  if (dom && !dom.isCollapsed) return;
114
373
  copySelection(selection);
115
374
  };
116
- target.addEventListener("copy", onCopy);
117
- target.addEventListener("keydown", onKeyDown);
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
- target.removeEventListener("copy", onCopy);
123
- target.removeEventListener("keydown", onKeyDown);
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
- * THE anchored projection: anchor → screen position, in one pure call —
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 box = projector.toScreen(anchor.pon, anchor.bounds);
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
- return positionAnchoredRect(box, placement, gap, anchor.avoid?.length ? projector.toScreenPoint(anchor.pon, anchor.avoid[0]) : null);
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 BROWSER-driven invalidation: an element in normal
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
- function positionAnchoredRect(box, placement, gap, avoid) {
167
- const cx = box.x + box.width / 2;
168
- const cy = box.y + box.height / 2;
169
- switch (placement) {
170
- case "bottom": return {
171
- left: cx,
172
- top: Math.max(box.y + box.height, avoid ? avoid.y : -Infinity) + gap,
173
- transform: "translate(-50%, 0)"
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
- left: Math.min(box.x, avoid ? avoid.x : Infinity) - gap,
177
- top: cy,
178
- transform: "translate(-100%, -50%)"
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
- left: Math.max(box.x + box.width, avoid ? avoid.x : -Infinity) + gap,
182
- top: cy,
183
- transform: "translate(0, -50%)"
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
- default: return {
186
- left: cx,
187
- top: Math.min(box.y, avoid ? avoid.y : Infinity) - gap,
188
- transform: "translate(-50%, -100%)"
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 ONCE here so every
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
- * WITHOUT an href — activation still reports through the plugin, so an
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 resolve as https), else
224
- * `null`. Never throws — a malformed URI is just an unopenable one.
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 trimmed = uri.trim();
228
- if (!trimmed) return null;
229
- const allowed = new Set([...allowedSchemes].map((s) => s.endsWith(":") ? s.toLowerCase() : `${s.toLowerCase()}:`));
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
- const url = new URL(trimmed, "https://invalid.example/");
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 DEFAULT actions UI adapter — the origin×phase visibility matrix plus
241
- * the browser fallbacks, written ONCE here so every framework binding
242
- * (react, angular, …) ships the SAME policy instead of forking it per
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 EVERY effect
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` AUTHORITY
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, opts) => {
847
+ openUri: (uri, options) => {
262
848
  const current = overridesOf();
263
849
  if (current?.openUri) {
264
- current.openUri(uri, opts);
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: (opts) => {
856
+ print: (options) => {
271
857
  const current = overridesOf();
272
- if (current?.print) current.print(opts);
273
- else if (opts && opts.origin !== "user") {} else if (typeof globalThis.print === "function") globalThis.print();
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, opts) => {
861
+ alert: (message, options) => {
276
862
  const current = overridesOf();
277
- if (current?.alert) current.alert(message, opts);
278
- else if (opts.origin === "lifecycle" || opts.phase === "boot") {} else if (typeof globalThis.alert === "function") globalThis.alert(message);
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, opts) => {
866
+ gotoPage: (page, effectContext) => {
281
867
  const current = overridesOf();
282
- if (current?.gotoPage) current.gotoPage(page, opts);
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 HOST APP installed under `handlerName` — an explicit
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 h = handler();
381
- if (h) h.postMessage({ family: "selection" });
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 h = handler();
386
- if (h) h.postMessage({
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 h = handler();
394
- if (h) h.postMessage({
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(e) {
408
- if (e.deltaMode !== 0) return Math.pow(NOTCH, -Math.sign(e.deltaY));
409
- if (e.ctrlKey) {
410
- if (Math.abs(e.deltaY) >= 100) return Math.pow(NOTCH, -Math.sign(e.deltaY));
411
- return Math.exp(-e.deltaY / 100);
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(-e.deltaY * .0015);
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 ONE DOM input binding for a Stage surface,
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 RAW:
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 MODALITY-AWARE:
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 CANCELS it into a pinch (the Notes/Procreate
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-COALESCED: pointer events only update gesture state;
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 STRUCTURAL
446
- * {@link StageGestureHost} interface (satisfied by `StageCapability`) and to
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 NOT fling. Pure; exported for tests.
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(el, host, options) {
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 = (e) => {
1103
+ const clickCount = (event) => {
518
1104
  const now = Date.now();
519
- mCount = now - mLast <= 400 && Math.hypot(e.clientX - mX, e.clientY - mY) <= 6 ? mCount + 1 : 1;
1105
+ mCount = now - mLast <= 400 && Math.hypot(event.clientX - mX, event.clientY - mY) <= 6 ? mCount + 1 : 1;
520
1106
  mLast = now;
521
- mX = e.clientX;
522
- mY = e.clientY;
1107
+ mX = event.clientX;
1108
+ mY = event.clientY;
523
1109
  return mCount;
524
1110
  };
525
1111
  const vpt = (clientX, clientY) => {
526
- const r = el.getBoundingClientRect();
1112
+ const rect = element.getBoundingClientRect();
527
1113
  return {
528
- x: clientX - r.left,
529
- y: clientY - r.top
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((p) => {
535
- if (p.kind === "touch") out.push(p);
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 v = computeReleaseVelocity(trail, performance.now());
586
- if (v && Math.hypot(v.vx, v.vy) >= FLING_MIN) host.fling(v.vx, v.vy);
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 p = pointers.get(panId);
624
- if (p && dirty) flushPanTo(p.x, p.y);
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 [a, b] = touches();
628
- if (a && b && dirty) flushPinch(a.x, a.y, b.x, b.y);
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 [a, b] = touches();
637
- if (!a || !b) return;
1222
+ const [first, second] = touches();
1223
+ if (!first || !second) return;
638
1224
  mode = "pinch";
639
1225
  lastApplied = {
640
- x: (a.x + b.x) / 2,
641
- y: (a.y + b.y) / 2
1226
+ x: (first.x + second.x) / 2,
1227
+ y: (first.y + second.y) / 2
642
1228
  };
643
- lastSpan = Math.hypot(a.x - b.x, a.y - b.y);
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 = (e) => {
657
- const kind = e.pointerType || "mouse";
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" && e.button !== 0) return;
1246
+ if (kind === "mouse" && event.button !== 0) return;
661
1247
  if (mode !== "idle") return;
662
- pointers.set(e.pointerId, {
663
- id: e.pointerId,
1248
+ pointers.set(event.pointerId, {
1249
+ id: event.pointerId,
664
1250
  kind,
665
- x: e.clientX,
666
- y: e.clientY,
667
- downX: e.clientX,
668
- downY: e.clientY
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(e, clickCount(e));
1258
+ sink.down(event, clickCount(event));
673
1259
  } else {
674
1260
  mode = "mousedrag";
675
1261
  begin();
676
- panId = e.pointerId;
1262
+ panId = event.pointerId;
677
1263
  lastApplied = {
678
- x: e.clientX,
679
- y: e.clientY
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(e.pointerId, {
688
- id: e.pointerId,
1273
+ pointers.set(event.pointerId, {
1274
+ id: event.pointerId,
689
1275
  kind,
690
- x: e.clientX,
691
- y: e.clientY,
692
- downX: e.clientX,
693
- downY: e.clientY
1276
+ x: event.clientX,
1277
+ y: event.clientY,
1278
+ downX: event.clientX,
1279
+ downY: event.clientY
694
1280
  });
695
- const moving = host.cameraInMotion();
696
- if (!moving && sink?.claimsPoint?.(e)) {
1281
+ const moving = host.isMoving();
1282
+ if (!moving && sink?.claimsPoint?.(event)) {
697
1283
  mode = "tool";
698
1284
  touchToolGesture = true;
699
- sink.down(e, 1);
1285
+ sink.down(event, 1);
700
1286
  break;
701
1287
  }
702
1288
  mode = "pending";
703
- downEvent = e;
1289
+ downEvent = event;
704
1290
  suppressTap = moving;
705
1291
  begin(true);
706
1292
  trail = [];
707
- pushSample(e.clientX, e.clientY);
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(e.pointerId, {
715
- id: e.pointerId,
1300
+ pointers.set(event.pointerId, {
1301
+ id: event.pointerId,
716
1302
  kind,
717
- x: e.clientX,
718
- y: e.clientY,
719
- downX: e.clientX,
720
- downY: e.clientY
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(e);
1313
+ sink?.cancel(event);
728
1314
  touchToolGesture = false;
729
- pointers.set(e.pointerId, {
730
- id: e.pointerId,
1315
+ pointers.set(event.pointerId, {
1316
+ id: event.pointerId,
731
1317
  kind,
732
- x: e.clientX,
733
- y: e.clientY,
734
- downX: e.clientX,
735
- downY: e.clientY
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 = (e) => {
745
- const p = pointers.get(e.pointerId);
746
- if (!p) return;
747
- if (p.kind === "touch") lastTouchAt = performance.now();
748
- p.x = e.clientX;
749
- p.y = e.clientY;
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(p.x, p.y);
753
- if (Math.hypot(p.x - p.downX, p.y - p.downY) > SLOP) {
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 = p.id;
1342
+ panId = pointer.id;
757
1343
  lastApplied = {
758
- x: p.x,
759
- y: p.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 (p.id !== panId) break;
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(p.x - graceSample.x, p.y - graceSample.y) / dt;
1357
+ const speed = Math.hypot(pointer.x - graceSample.x, pointer.y - graceSample.y) / dt;
772
1358
  graceSample = {
773
1359
  t: nowT,
774
- x: p.x,
775
- y: p.y
1360
+ x: pointer.x,
1361
+ y: pointer.y
776
1362
  };
777
1363
  if (speed < SETTLE_SPEED) panGraceUntil = 0;
778
1364
  }
779
- p.downX = p.x;
780
- p.downY = p.y;
1365
+ pointer.downX = pointer.x;
1366
+ pointer.downY = pointer.y;
781
1367
  break;
782
1368
  }
783
- if (Math.hypot(p.x - p.downX, p.y - p.downY) <= SLOP) break;
1369
+ if (Math.hypot(pointer.x - pointer.downX, pointer.y - pointer.downY) <= SLOP) break;
784
1370
  panArmed = true;
785
1371
  lastApplied = {
786
- x: p.x,
787
- y: p.y
1372
+ x: pointer.x,
1373
+ y: pointer.y
788
1374
  };
789
1375
  trail = [];
790
1376
  spanTrail = [];
791
1377
  }
792
- pushSample(p.x, p.y);
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(e);
1385
+ sink?.move(event);
800
1386
  break;
801
1387
  case "mousedrag":
802
- if (p.id === panId) dirty = true;
1388
+ if (pointer.id === panId) dirty = true;
803
1389
  break;
804
1390
  case "idle": break;
805
1391
  }
806
1392
  };
807
- const onHoverMove = (e) => {
808
- if (mode === "idle" && sink) sink.hover(e);
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 = (e) => {
833
- const p = pointers.get(e.pointerId);
834
- if (!p) return;
835
- if (p.kind === "touch") lastTouchAt = performance.now();
836
- pointers.delete(e.pointerId);
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(e.clientX - lastTapX, e.clientY - lastTapY) <= DOUBLE_TAP_RADIUS;
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(e.clientX, e.clientY));
1432
+ host.doubleTapZoom(vpt(event.clientX, event.clientY));
847
1433
  } else {
848
1434
  lastTapT = now;
849
- lastTapX = e.clientX;
850
- lastTapY = e.clientY;
1435
+ lastTapX = event.clientX;
1436
+ lastTapY = event.clientY;
851
1437
  if (sink && downEvent) {
852
1438
  sink.down(downEvent, 1);
853
- sink.up(e);
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 v = computeReleaseVelocity(trail, now);
1448
+ const velocity = computeReleaseVelocity(trail, now);
863
1449
  const vs = computeReleaseVelocity(spanTrail, now);
864
1450
  end();
865
- if (v) {
866
- const speed = Math.hypot(v.vx, v.vy);
867
- if (speed >= FLING_MIN && speed > Math.abs(vs?.vx ?? 0)) host.fling(v.vx, v.vy);
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(e.clientX, e.clientY);
873
- pushSample(e.clientX, e.clientY);
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(e);
1491
+ sink?.up(event);
906
1492
  toIdle();
907
1493
  break;
908
1494
  case "mousedrag":
909
- if (p.id !== panId) break;
910
- flushPanTo(e.clientX, e.clientY);
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 = (e) => {
918
- if (!pointers.get(e.pointerId)) return;
919
- pointers.delete(e.pointerId);
1503
+ const onCancel = (event) => {
1504
+ if (!pointers.get(event.pointerId)) return;
1505
+ pointers.delete(event.pointerId);
920
1506
  if (mode === "tool") {
921
- sink?.cancel(e);
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 = (e) => {
930
- e.preventDefault();
931
- if (zoomGestures && (e.ctrlKey || e.metaKey)) host.zoomAround(vpt(e.clientX, e.clientY), wheelZoom(e));
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 = e.shiftKey ? e.deltaY : e.deltaX;
934
- const dy = e.shiftKey ? e.deltaX : e.deltaY;
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 = (e) => {
940
- e.preventDefault();
941
- lastScale = e.scale ?? 1;
1525
+ const onGestureStart = (event) => {
1526
+ event.preventDefault();
1527
+ lastScale = event.scale ?? 1;
942
1528
  };
943
- const onGestureChange = (e) => {
944
- e.preventDefault();
1529
+ const onGestureChange = (event) => {
1530
+ event.preventDefault();
945
1531
  if (touches().length > 0 || performance.now() - lastTouchAt < 500) return;
946
- const g = e;
947
- const scale = g.scale ?? 1;
948
- if (zoomGestures && scale > 0) host.zoomAround(vpt(g.clientX, g.clientY), scale / lastScale);
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
- el.addEventListener("pointerdown", onDown);
952
- el.addEventListener("pointermove", onHoverMove);
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
- el.addEventListener("wheel", onWheel, { passive: false });
1542
+ element.addEventListener("wheel", onWheel, { passive: false });
957
1543
  const hasGestureEvents = "GestureEvent" in window;
958
1544
  if (hasGestureEvents) {
959
- el.addEventListener("gesturestart", onGestureStart);
960
- el.addEventListener("gesturechange", onGestureChange);
961
- el.addEventListener("gestureend", onGestureStart);
1545
+ element.addEventListener("gesturestart", onGestureStart);
1546
+ element.addEventListener("gesturechange", onGestureChange);
1547
+ element.addEventListener("gestureend", onGestureStart);
962
1548
  }
963
1549
  return () => {
964
- el.removeEventListener("pointerdown", onDown);
965
- el.removeEventListener("pointermove", onHoverMove);
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
- el.removeEventListener("wheel", onWheel);
1555
+ element.removeEventListener("wheel", onWheel);
970
1556
  if (hasGestureEvents) {
971
- el.removeEventListener("gesturestart", onGestureStart);
972
- el.removeEventListener("gesturechange", onGestureChange);
973
- el.removeEventListener("gestureend", onGestureStart);
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(el, options) {
1569
+ function attachSelectionHandle(element, options) {
984
1570
  let active = null;
985
- const onMove = (e) => {
986
- if (!active || e.pointerId !== active.pointerId) return;
1571
+ const onMove = (event) => {
1572
+ if (!active || event.pointerId !== active.pointerId) return;
987
1573
  active.session.move({
988
- x: active.base.x + (e.clientX - active.baseClient.x),
989
- y: active.base.y + (e.clientY - active.baseClient.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 = (e) => {
993
- if (!active || e.pointerId !== active.pointerId) return;
994
- const s = active.session;
1578
+ const onUp = (event) => {
1579
+ if (!active || event.pointerId !== active.pointerId) return;
1580
+ const session = active.session;
995
1581
  active = null;
996
- s.end();
1582
+ session.end();
997
1583
  };
998
- const onDown = (e) => {
1584
+ const onDown = (event) => {
999
1585
  if (active) return;
1000
1586
  const armed = options.arm();
1001
1587
  if (!armed) return;
1002
- e.preventDefault();
1003
- e.stopPropagation();
1588
+ event.preventDefault();
1589
+ event.stopPropagation();
1004
1590
  try {
1005
- el.setPointerCapture(e.pointerId);
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: e.clientX,
1012
- y: e.clientY
1597
+ x: event.clientX,
1598
+ y: event.clientY
1013
1599
  },
1014
- pointerId: e.pointerId
1600
+ pointerId: event.pointerId
1015
1601
  };
1016
1602
  };
1017
- el.addEventListener("pointerdown", onDown);
1018
- el.addEventListener("pointermove", onMove);
1019
- el.addEventListener("pointerup", onUp);
1020
- el.addEventListener("pointercancel", onUp);
1603
+ element.addEventListener("pointerdown", onDown);
1604
+ element.addEventListener("pointermove", onMove);
1605
+ element.addEventListener("pointerup", onUp);
1606
+ element.addEventListener("pointercancel", onUp);
1021
1607
  return () => {
1022
- el.removeEventListener("pointerdown", onDown);
1023
- el.removeEventListener("pointermove", onMove);
1024
- el.removeEventListener("pointerup", onUp);
1025
- el.removeEventListener("pointercancel", onUp);
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 ONE place a PointerEvent becomes a
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 STRUCTURAL interfaces ({@link StageSurfaceHost},
1635
+ * stage and the hub through structural interfaces ({@link StageSurfaceHost},
1050
1636
  * {@link StageSurfaceHub}) — satisfied by `StageCapability` and
1051
- * `InteractionCapability`, imported by neither. @embedpdf/web stays free of
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(el, stage, options = {}) {
1644
+ function createStageSurface(element, stage, options = {}) {
1059
1645
  const hub = options.hub ?? null;
1060
1646
  const cleanups = [];
1061
- const setViewport = () => stage.setViewport({
1062
- width: el.clientWidth,
1063
- height: el.clientHeight
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(el);
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, e, clickCount = 1, gesture) => {
1079
- const r = el.getBoundingClientRect();
1664
+ const sampleOf = (phase, event, clickCount = 1, gesture) => {
1665
+ const rect = element.getBoundingClientRect();
1080
1666
  const viewport = {
1081
- x: e.clientX - r.left,
1082
- y: e.clientY - r.top
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.pageAt(viewport) ?? void 0,
1088
- project: (pon) => stage.pointOnPage(pon, viewport),
1673
+ page: stage.getPageAt(viewport) ?? void 0,
1674
+ project: (page) => stage.viewportToPage(page, viewport),
1089
1675
  modifiers: {
1090
- shift: e.shiftKey,
1091
- alt: e.altKey,
1092
- ctrl: e.ctrlKey,
1093
- meta: e.metaKey
1676
+ shift: event.shiftKey,
1677
+ alt: event.altKey,
1678
+ ctrl: event.ctrlKey,
1679
+ meta: event.metaKey
1094
1680
  },
1095
1681
  clickCount,
1096
- pointerType: e.pointerType || "mouse",
1682
+ pointerType: event.pointerType || "mouse",
1097
1683
  ...gesture ? { gesture } : {},
1098
1684
  ...options.source ? { source: options.source } : {}
1099
1685
  };
1100
1686
  };
1101
- const forward = (phase, e, clickCount = 1, gesture) => {
1102
- hub?.dispatch(sampleOf(phase, e, clickCount, gesture));
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: (e, clickCount) => forward("down", e, clickCount),
1106
- move: (e) => forward("move", e),
1107
- up: (e) => forward("up", e),
1108
- cancel: (e) => forward("cancel", e),
1109
- hover: (e) => forward("move", e),
1110
- longPress: (e) => forward("down", e, 2, "long-press"),
1111
- claimsPoint: (e) => !!hub.activeTool().touchDirect || hub.wouldClaimTouch(sampleOf("down", e))
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(el, stage, {
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, opts = {}) {
1126
- const storeName = opts.storeName ?? "bytes";
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 name is the font's KEY — the CSS family the
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 LAST holder unmounts).
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(key);
1782
+ const existing = holders.get(family);
1180
1783
  if (existing) {
1181
1784
  existing.count++;
1182
- return () => release(holders, key);
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(key, buffer, {
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(key, {
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(key);
1801
+ holders.delete(family);
1199
1802
  return () => {};
1200
1803
  }
1201
1804
  notifyFontChange(doc);
1202
- return () => release(holders, key);
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, key) {
1235
- const holder = holders.get(key);
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(key);
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 m = ctx.measureText("Hg");
1316
- if (typeof m.fontBoundingBoxAscent !== "number" || typeof m.fontBoundingBoxDescent !== "number") return null;
1954
+ const metrics = ctx.measureText("Hg");
1955
+ if (typeof metrics.fontBoundingBoxAscent !== "number" || typeof metrics.fontBoundingBoxDescent !== "number") return null;
1317
1956
  return {
1318
- ascent: m.fontBoundingBoxAscent / 100,
1319
- descent: m.fontBoundingBoxDescent / 100
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 ONE contentEditable mechanics module for a
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 POLICY — what a
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 (by design, recorded in the plan): the browser's native undo
1377
- * stack does not span a host-driven restyle; DOM line wrapping approximates
1378
- * the engine's layout, the appearance stream is the truth.
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 n = node;;) {
1400
- const parent = n.parentNode;
2038
+ for (let current = node;;) {
2039
+ const parent = current.parentNode;
1401
2040
  if (!parent) return false;
1402
- if (lastChild(parent) !== n) return false;
2041
+ if (lastChild(parent) !== current) return false;
1403
2042
  if (parent === root || isBlock(parent)) return true;
1404
- n = parent;
2043
+ current = parent;
1405
2044
  }
1406
2045
  }
1407
2046
  function contains(root, node) {
1408
- for (let n = node; n; n = n.parentNode) if (n === root) return true;
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 m = RGB.exec(trimmed);
1417
- if (!m) return void 0;
1418
- const hex = (n) => Math.max(0, Math.min(255, Number(n))).toString(16).padStart(2, "0");
1419
- return `#${hex(m[1])}${hex(m[2])}${hex(m[3])}`.toUpperCase();
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(n) {
1426
- return Math.round(n * 100) / 100;
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((p) => p === "underline" || p === "line-through");
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((d) => d === "word" ? "underline" : d);
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(a, b) {
1483
- const da = a ?? {};
1484
- const db = b ?? {};
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((e) => e.kind === "text" && e.node === position.node);
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((e) => e.kind === "break" && e.node === position.node);
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((e) => e.kind === "exit" && e.node === position.node.parentNode);
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((e) => e.node === child || e.kind !== "exit" && contains(child, e.node));
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((e) => e.kind === "exit" && e.node === position.node);
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((e) => e.kind === "text" && offset >= e.at && offset <= e.at + e.length);
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((e) => e.kind === "enter" && isBlock(e.node) && e.at === offset);
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((e) => e.kind === "break" && e.at + 1 === offset);
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 e = events[i];
1706
- if (e.kind === "text") return {
1707
- node: e.node,
1708
- offset: e.length
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(sel, shadowRoot) {
1720
- const s = sel;
1721
- if (typeof s.getComposedRanges !== "function") return null;
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 = s.getComposedRanges(...args);
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(a, b) {
1729
- if (a.paragraphs.length !== b.paragraphs.length) return false;
1730
- for (let i = 0; i < a.paragraphs.length; i++) {
1731
- const pa = a.paragraphs[i];
1732
- const pb = b.paragraphs[i];
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(el, host, initial) {
1745
- const root = el;
1746
- const doc = el.ownerDocument;
1747
- const rootNode = el.getRootNode();
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 sel = doc.getSelection();
1751
- if (!sel) return null;
2389
+ const selection = doc.getSelection();
2390
+ if (!selection) return null;
1752
2391
  if (rootNode !== doc) {
1753
- const composed = composedRange(sel, rootNode);
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 sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
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 sel = doc.getSelection();
1784
- if (!sel) return;
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 sel.setBaseAndExtent === "function") {
1789
- sel.setBaseAndExtent(start.node, start.offset, end.node, end.offset);
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
- sel.removeAllRanges();
1796
- sel.addRange(domRange);
2434
+ selection.removeAllRanges();
2435
+ selection.addRange(domRange);
1797
2436
  };
1798
2437
  const refreshLineModel = () => {
1799
- const bodyStyle = doc.defaultView?.getComputedStyle(el) ?? el.style;
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(el.querySelectorAll("span, b, strong, i, em")).map((node) => {
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
- el.style.lineHeight = String(lineHeight);
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 = (e) => {
1862
- if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
1863
- const key = e.key.toLowerCase();
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
- e.preventDefault();
2505
+ event.preventDefault();
1867
2506
  host.onCommand(command);
1868
2507
  };
1869
- const onPaste = (e) => {
1870
- e.preventDefault();
1871
- const text = e.clipboardData?.getData("text/plain") ?? "";
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() !== el) return;
2514
+ if (activeElement() !== element) return;
1876
2515
  host.onSelectionChange(currentSelection());
1877
2516
  };
1878
- el.addEventListener("input", onInput);
1879
- el.addEventListener("compositionstart", onCompositionStart);
1880
- el.addEventListener("compositionend", onCompositionEnd);
1881
- el.addEventListener("keydown", onKeyDown);
1882
- el.addEventListener("paste", onPaste);
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() === el);
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
- el.removeEventListener("input", onInput);
1902
- el.removeEventListener("compositionstart", onCompositionStart);
1903
- el.removeEventListener("compositionend", onCompositionEnd);
1904
- el.removeEventListener("keydown", onKeyDown);
1905
- el.removeEventListener("paste", onPaste);
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