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