@embedpdf/web 3.0.0-next.1 → 3.0.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -60,6 +60,136 @@ function saveFile(bytes, name, mimeType) {
60
60
  setTimeout(() => URL.revokeObjectURL(url), 1e3);
61
61
  }
62
62
  //#endregion
63
+ //#region src/clipboard.ts
64
+ const PREFETCH_DEBOUNCE_MS = 150;
65
+ /**
66
+ * Wire clipboard copy for a selection. Two delivery paths, both fed by the
67
+ * prefetched text:
68
+ *
69
+ * - the native `copy` event (menu Edit→Copy, or ctrl/cmd+C while the page
70
+ * has a DOM selection or focused editable — e.g. the viewer shell's
71
+ * focus sink): answered SYNCHRONOUSLY from the cache;
72
+ * - ctrl/cmd+C with no DOM selection (canvas-rendered viewers usually
73
+ * have none, and browsers don't dispatch `copy` then): a keydown
74
+ * fallback writes via the async Clipboard API inside the keystroke's
75
+ * user-activation window.
76
+ *
77
+ * Returns the unwire function. Mount once per document view (not per page).
78
+ */
79
+ function wireSelectionClipboard(selection, options = {}) {
80
+ const target = options.target ?? document;
81
+ const prefetch = options.prefetch ?? true;
82
+ let cached = null;
83
+ let generation = 0;
84
+ let timer = null;
85
+ const refresh = () => {
86
+ cached = null;
87
+ if (!prefetch || !selection.hasSelection() || !selection.canCopy()) return;
88
+ const gen = ++generation;
89
+ selection.readText().then((text) => {
90
+ if (gen === generation) cached = text;
91
+ }, () => {});
92
+ };
93
+ const offCommit = selection.onCommit(() => {
94
+ if (timer) clearTimeout(timer);
95
+ timer = null;
96
+ refresh();
97
+ });
98
+ const offChange = selection.onChange(() => {
99
+ generation++;
100
+ cached = null;
101
+ if (timer) clearTimeout(timer);
102
+ timer = setTimeout(refresh, PREFETCH_DEBOUNCE_MS);
103
+ });
104
+ const onCopy = (e) => {
105
+ if (cached == null || cached === "" || !selection.hasSelection()) return;
106
+ e.clipboardData?.setData("text/plain", cached);
107
+ e.preventDefault();
108
+ };
109
+ const onKeyDown = (e) => {
110
+ if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "c" || e.shiftKey || e.altKey) return;
111
+ if (!selection.hasSelection() || !selection.canCopy()) return;
112
+ const dom = target.getSelection?.();
113
+ if (dom && !dom.isCollapsed) return;
114
+ copySelection(selection);
115
+ };
116
+ target.addEventListener("copy", onCopy);
117
+ target.addEventListener("keydown", onKeyDown);
118
+ return () => {
119
+ offCommit();
120
+ offChange();
121
+ if (timer) clearTimeout(timer);
122
+ target.removeEventListener("copy", onCopy);
123
+ target.removeEventListener("keydown", onKeyDown);
124
+ };
125
+ }
126
+ /**
127
+ * Toolbar/menu path: read the selected text and write it to the clipboard
128
+ * via the async Clipboard API. Call from a user-gesture handler (browsers
129
+ * require a secure context + user activation for clipboard writes). Resolves
130
+ * with what was copied ('' when nothing was — nothing is written then, so an
131
+ * empty selection never clobbers the user's clipboard).
132
+ */
133
+ async function copySelection(selection) {
134
+ const text = await selection.readText();
135
+ if (text !== "") await navigator.clipboard.writeText(text);
136
+ return text;
137
+ }
138
+ //#endregion
139
+ //#region src/anchored-position.ts
140
+ /**
141
+ * THE anchored projection: anchor → screen position, in one pure call —
142
+ * projection, avoid-point transform, and the shared placement policy.
143
+ * Frameworks recompute this inside their native reactive primitive
144
+ * (a React render, a Vue `computed`, an Angular signal, a Svelte
145
+ * `$derived`); nothing here schedules anything.
146
+ */
147
+ function projectAnchoredTarget(projector, anchor, placement, gap) {
148
+ const box = projector.toScreen(anchor.pon, anchor.bounds);
149
+ if (!box) return null;
150
+ return positionAnchoredRect(box, placement, gap, anchor.avoid?.length ? projector.toScreenPoint(anchor.pon, anchor.avoid[0]) : null);
151
+ }
152
+ /**
153
+ * The one genuinely BROWSER-driven invalidation: an element in normal
154
+ * document flow moves when the document scrolls or the window resizes —
155
+ * no state change announces it. `<PageView>`'s binding registers this;
156
+ * the Stage never does (its camera is state, delivered through render).
157
+ */
158
+ function observeClientGeometry(callback) {
159
+ window.addEventListener("scroll", callback, true);
160
+ window.addEventListener("resize", callback);
161
+ return () => {
162
+ window.removeEventListener("scroll", callback, true);
163
+ window.removeEventListener("resize", callback);
164
+ };
165
+ }
166
+ function positionAnchoredRect(box, placement, gap, avoid) {
167
+ const cx = box.x + box.width / 2;
168
+ const cy = box.y + box.height / 2;
169
+ switch (placement) {
170
+ case "bottom": return {
171
+ left: cx,
172
+ top: Math.max(box.y + box.height, avoid ? avoid.y : -Infinity) + gap,
173
+ transform: "translate(-50%, 0)"
174
+ };
175
+ case "left": return {
176
+ left: Math.min(box.x, avoid ? avoid.x : Infinity) - gap,
177
+ top: cy,
178
+ transform: "translate(-100%, -50%)"
179
+ };
180
+ case "right": return {
181
+ left: Math.max(box.x + box.width, avoid ? avoid.x : -Infinity) + gap,
182
+ top: cy,
183
+ transform: "translate(0, -50%)"
184
+ };
185
+ default: return {
186
+ left: cx,
187
+ top: Math.min(box.y, avoid ? avoid.y : Infinity) - gap,
188
+ transform: "translate(-50%, -100%)"
189
+ };
190
+ }
191
+ }
192
+ //#endregion
63
193
  //#region src/cursor.ts
64
194
  /** Build the CSS cursor value: `url("data:image/svg+xml,…") x y, fallback`. */
65
195
  function svgCursor({ svg, hotspot, fallback = "crosshair" }) {
@@ -105,10 +235,909 @@ function sanitizeExternalUri(uri, allowedSchemes = DEFAULT_ALLOWED_SCHEMES) {
105
235
  }
106
236
  }
107
237
  //#endregion
238
+ //#region src/actions-ui.ts
239
+ /**
240
+ * The DEFAULT actions UI adapter — the origin×phase visibility matrix plus
241
+ * the browser fallbacks, written ONCE here so every framework binding
242
+ * (react, angular, …) ships the SAME policy instead of forking it per
243
+ * framework. Types are structural twins of `@embedpdf/plugin-actions`'
244
+ * adapter contract (declared here so this package stays plugin-free, per
245
+ * the layering law); assignability is checked where a binding installs it.
246
+ *
247
+ * The matrix (defaults only — an embedder override receives EVERY effect
248
+ * with its context attached and decides for itself):
249
+ * - URIs open through `sanitizeExternalUri` in a new tab (the dispatcher
250
+ * already policy-gated the node; blocked schemes are dropped here).
251
+ * - Print: hover/lifecycle-origin requests never open the dialog; user
252
+ * requests fall back to `globalThis.print`. The `doc.print` AUTHORITY
253
+ * gate is upstream in the plugin and not overridable.
254
+ * - Alert: document-open nags (lifecycle origin, boot phase) never reach
255
+ * `window.alert` — the boot-nag gap, closed.
256
+ * - gotoPage: delegates to the binding-supplied navigator (the stage).
257
+ */
258
+ const createDefaultActionsUiAdapter = (options = {}) => {
259
+ const overridesOf = () => typeof options.overrides === "function" ? options.overrides() : options.overrides;
260
+ return {
261
+ openUri: (uri, opts) => {
262
+ const current = overridesOf();
263
+ if (current?.openUri) {
264
+ current.openUri(uri, opts);
265
+ return;
266
+ }
267
+ const href = sanitizeExternalUri(uri);
268
+ if (href && typeof window !== "undefined") window.open(href, "_blank", "noopener,noreferrer");
269
+ },
270
+ print: (opts) => {
271
+ const current = overridesOf();
272
+ if (current?.print) current.print(opts);
273
+ else if (opts && opts.origin !== "user") {} else if (typeof globalThis.print === "function") globalThis.print();
274
+ },
275
+ alert: (message, opts) => {
276
+ const current = overridesOf();
277
+ if (current?.alert) current.alert(message, opts);
278
+ else if (opts.origin === "lifecycle" || opts.phase === "boot") {} else if (typeof globalThis.alert === "function") globalThis.alert(message);
279
+ },
280
+ gotoPage: (page, opts) => {
281
+ const current = overridesOf();
282
+ if (current?.gotoPage) current.gotoPage(page, opts);
283
+ else options.goToPage?.(page);
284
+ }
285
+ };
286
+ };
287
+ //#endregion
288
+ //#region src/painted-image.ts
289
+ /**
290
+ * Bind a raster source to an <img> without ever painting its incomplete-image
291
+ * placeholder. The source becomes visible on load; retained coverage is
292
+ * released only after the browser has had one full frame to present it.
293
+ *
294
+ * Call this from the framework's pre-paint mount hook and invoke the returned
295
+ * cleanup on unmount. Keeping this DOM lifecycle here lets every framework
296
+ * adapter remain a ref plus one bind/unbind hook.
297
+ */
298
+ function bindPaintedImage(image, source, callbacks) {
299
+ const controller = new AbortController();
300
+ let revoke;
301
+ let frame = 0;
302
+ let painted = false;
303
+ image.style.visibility = "hidden";
304
+ const onLoad = () => {
305
+ image.style.visibility = "visible";
306
+ frame = requestAnimationFrame(() => {
307
+ frame = requestAnimationFrame(() => {
308
+ frame = 0;
309
+ painted = true;
310
+ callbacks.onPainted();
311
+ });
312
+ });
313
+ };
314
+ image.addEventListener("load", onLoad, { once: true });
315
+ source.objectUrl(controller.signal).then((objectUrl) => {
316
+ if (controller.signal.aborted) {
317
+ objectUrl.revoke();
318
+ return;
319
+ }
320
+ revoke = objectUrl.revoke;
321
+ image.src = objectUrl.url;
322
+ }, () => {});
323
+ return () => {
324
+ controller.abort();
325
+ if (frame) cancelAnimationFrame(frame);
326
+ image.removeEventListener("load", onLoad);
327
+ image.style.visibility = "hidden";
328
+ image.removeAttribute("src");
329
+ revoke?.();
330
+ if (painted) callbacks.onUnpainted();
331
+ };
332
+ }
333
+ //#endregion
334
+ //#region src/feedback.ts
335
+ const vibrate = (pattern) => {
336
+ if (typeof navigator !== "undefined") navigator.vibrate?.(pattern);
337
+ };
338
+ /**
339
+ * The default web provider: the Vibration API, mapped onto the three haptic
340
+ * families (selection = the shortest tick; impact scales with weight; notify
341
+ * uses patterns). A no-op wherever vibration is unsupported — notably iOS
342
+ * Safari — so it is always safe to install.
343
+ */
344
+ const vibrationFeedback = {
345
+ selection: () => vibrate(8),
346
+ impact: (weight = "light") => vibrate(weight === "heavy" ? 30 : weight === "medium" ? 20 : 10),
347
+ notify: (kind) => vibrate(kind === "error" ? [
348
+ 12,
349
+ 60,
350
+ 12,
351
+ 60,
352
+ 12
353
+ ] : kind === "warning" ? [
354
+ 15,
355
+ 70,
356
+ 15
357
+ ] : [
358
+ 10,
359
+ 50,
360
+ 10
361
+ ])
362
+ };
363
+ /**
364
+ * A provider for native iOS shells: forwards each call to a WKWebView script
365
+ * message handler the HOST APP installed under `handlerName` — an explicit
366
+ * contract between the embedder and their own app, never a global this library
367
+ * sniffs on its own. The message shape is
368
+ * `{ family: 'selection' } | { family: 'impact', weight } | { family: 'notify', kind }`;
369
+ * the native side maps families onto the matching `UIFeedbackGenerator`.
370
+ * Calls fall back to `fallback` (default: {@link vibrationFeedback}) when the
371
+ * handler is absent, so one provider serves a hybrid web/app deployment.
372
+ */
373
+ const wkFeedback = (handlerName, fallback = vibrationFeedback) => {
374
+ const handler = () => {
375
+ if (typeof window === "undefined") return void 0;
376
+ return window.webkit?.messageHandlers?.[handlerName];
377
+ };
378
+ return {
379
+ selection: () => {
380
+ const h = handler();
381
+ if (h) h.postMessage({ family: "selection" });
382
+ else fallback.selection();
383
+ },
384
+ impact: (weight = "light") => {
385
+ const h = handler();
386
+ if (h) h.postMessage({
387
+ family: "impact",
388
+ weight
389
+ });
390
+ else fallback.impact(weight);
391
+ },
392
+ notify: (kind) => {
393
+ const h = handler();
394
+ if (h) h.postMessage({
395
+ family: "notify",
396
+ kind
397
+ });
398
+ else fallback.notify(kind);
399
+ }
400
+ };
401
+ };
402
+ //#endregion
403
+ //#region src/wheel.ts
404
+ /** The per-notch step for discrete wheels — the zoom buttons' ratio. */
405
+ const NOTCH = 1.2;
406
+ /** Zoom factor for a ctrl/cmd wheel event (the caller decides zoom vs pan). */
407
+ function wheelZoomFactor(e) {
408
+ if (e.deltaMode !== 0) return Math.pow(NOTCH, -Math.sign(e.deltaY));
409
+ if (e.ctrlKey) {
410
+ if (Math.abs(e.deltaY) >= 100) return Math.pow(NOTCH, -Math.sign(e.deltaY));
411
+ return Math.exp(-e.deltaY / 100);
412
+ }
413
+ return Math.exp(-e.deltaY * .0015);
414
+ }
415
+ //#endregion
416
+ //#region src/stage-gestures.ts
417
+ /**
418
+ * Stage gesture controller — the ONE DOM input binding for a Stage surface,
419
+ * shared by every framework adapter (React, Angular, …) so the feel can never
420
+ * drift between them.
421
+ *
422
+ * The premise (see the stage plugin's camera doctrine): desktop inputs arrive
423
+ * with physics already applied by the OS — momentum lives in the wheel stream,
424
+ * a trackpad pinch is one pre-arbitrated gesture stream. Touch arrives RAW:
425
+ * with `touch-action: none` the platform scroller is out of the loop, so the
426
+ * ballistics (velocity → fling) and the arbitration (is this contact a scroll,
427
+ * a pinch, a tap, or a tool gesture?) must be synthesized here.
428
+ *
429
+ * Arbitration is MODALITY-AWARE:
430
+ * - touch — navigation-first: one finger pans (whatever tool is armed), two
431
+ * fingers pinch-zoom around their centroid, release velocity flings, a tap
432
+ * forwards as a click, a double-tap zoom-toggles, a long-press hands the
433
+ * gesture to the interaction hub (text selection), and a second finger
434
+ * landing mid-tool-gesture CANCELS it into a pinch (the Notes/Procreate
435
+ * convention).
436
+ * - mouse/pen — tool-first, exactly the pre-existing behavior: with a hub,
437
+ * every down/move/up forwards (pan is the pan tool's job); without one,
438
+ * dragging pans. Wheel and Safari-trackpad gesture events are unchanged.
439
+ *
440
+ * Camera writes are rAF-COALESCED: pointer events only update gesture state;
441
+ * one animation-frame tick applies at most one pan and one zoom per frame,
442
+ * inside the host's begin/endGesture transaction. Events may arrive at 120 Hz;
443
+ * the camera moves at display rate.
444
+ *
445
+ * Dependency note: this module speaks to the stage through the STRUCTURAL
446
+ * {@link StageGestureHost} interface (satisfied by `StageCapability`) and to
447
+ * the interaction hub through {@link StageGestureSink} (a closure the adapter
448
+ * builds) — @embedpdf/web stays free of plugin imports, per the layering law.
449
+ */
450
+ /**
451
+ * Release velocity from a trail of pointer samples: the mean velocity over the
452
+ * trailing `windowMs` (first-to-last inside the window). Null when the trail is
453
+ * too thin or too stale to trust — the standard "held still, then let go"
454
+ * case, which must NOT fling. Pure; exported for tests.
455
+ */
456
+ function computeReleaseVelocity(samples, now, windowMs = 100) {
457
+ let firstIdx = -1;
458
+ for (let i = 0; i < samples.length; i++) if (now - samples[i].t <= windowMs) {
459
+ firstIdx = i;
460
+ break;
461
+ }
462
+ if (firstIdx < 0 || firstIdx === samples.length - 1) return null;
463
+ const first = samples[firstIdx];
464
+ const last = samples[samples.length - 1];
465
+ const dt = last.t - first.t;
466
+ if (dt < 10) return null;
467
+ return {
468
+ vx: (last.x - first.x) / dt * 1e3,
469
+ vy: (last.y - first.y) / dt * 1e3
470
+ };
471
+ }
472
+ /** Attach the gesture controller to a Stage container. Returns the detach fn. */
473
+ function createStageGestureController(el, host, options) {
474
+ const zoomGestures = options.zoomGestures ?? true;
475
+ const wheelZoom = options.wheelZoomFactor ?? wheelZoomFactor;
476
+ const sink = options.sink ?? null;
477
+ const LONG_PRESS_MS = options.longPressMs ?? 450;
478
+ const SLOP = options.tapSlopPx ?? 10;
479
+ const DOUBLE_TAP_MS = options.doubleTapMs ?? 300;
480
+ const DOUBLE_TAP_RADIUS = 25;
481
+ const FLING_MIN = options.flingMinVelocity ?? 50;
482
+ const MIN_PINCH_SPAN = 20;
483
+ const PINCH_RELEASE_GRACE_MS = 160;
484
+ const SETTLE_SPEED = .12;
485
+ const pointers = /* @__PURE__ */ new Map();
486
+ let mode = "idle";
487
+ let began = false;
488
+ let suppressTap = false;
489
+ let touchToolGesture = false;
490
+ let downEvent = null;
491
+ let longPressTimer = null;
492
+ let panId = -1;
493
+ let panArmed = true;
494
+ let panGraceUntil = 0;
495
+ let graceSample = {
496
+ t: 0,
497
+ x: 0,
498
+ y: 0
499
+ };
500
+ let lastTouchAt = -Infinity;
501
+ let frame = 0;
502
+ let dirty = false;
503
+ let lastApplied = {
504
+ x: 0,
505
+ y: 0
506
+ };
507
+ let lastSpan = 0;
508
+ let trail = [];
509
+ let spanTrail = [];
510
+ let lastTapT = 0;
511
+ let lastTapX = 0;
512
+ let lastTapY = 0;
513
+ let mLast = 0;
514
+ let mX = 0;
515
+ let mY = 0;
516
+ let mCount = 0;
517
+ const clickCount = (e) => {
518
+ const now = Date.now();
519
+ mCount = now - mLast <= 400 && Math.hypot(e.clientX - mX, e.clientY - mY) <= 6 ? mCount + 1 : 1;
520
+ mLast = now;
521
+ mX = e.clientX;
522
+ mY = e.clientY;
523
+ return mCount;
524
+ };
525
+ const vpt = (clientX, clientY) => {
526
+ const r = el.getBoundingClientRect();
527
+ return {
528
+ x: clientX - r.left,
529
+ y: clientY - r.top
530
+ };
531
+ };
532
+ const touches = () => {
533
+ const out = [];
534
+ pointers.forEach((p) => {
535
+ if (p.kind === "touch") out.push(p);
536
+ });
537
+ return out;
538
+ };
539
+ const clearLongPress = () => {
540
+ if (longPressTimer !== null) {
541
+ clearTimeout(longPressTimer);
542
+ longPressTimer = null;
543
+ }
544
+ };
545
+ const stopFrames = () => {
546
+ if (frame) {
547
+ cancelAnimationFrame(frame);
548
+ frame = 0;
549
+ }
550
+ };
551
+ const begin = (elastic = false) => {
552
+ if (!began) {
553
+ began = true;
554
+ host.beginGesture(elastic ? { elastic: true } : void 0);
555
+ }
556
+ };
557
+ const end = () => {
558
+ if (began) {
559
+ began = false;
560
+ host.endGesture();
561
+ }
562
+ };
563
+ const toIdle = () => {
564
+ mode = "idle";
565
+ downEvent = null;
566
+ touchToolGesture = false;
567
+ trail = [];
568
+ spanTrail = [];
569
+ dirty = false;
570
+ panId = -1;
571
+ panArmed = true;
572
+ panGraceUntil = 0;
573
+ clearLongPress();
574
+ stopFrames();
575
+ };
576
+ const pushSample = (x, y) => {
577
+ trail.push({
578
+ t: performance.now(),
579
+ x,
580
+ y
581
+ });
582
+ if (trail.length > 16) trail.shift();
583
+ };
584
+ const maybeFling = () => {
585
+ const v = computeReleaseVelocity(trail, performance.now());
586
+ if (v && Math.hypot(v.vx, v.vy) >= FLING_MIN) host.fling(v.vx, v.vy);
587
+ };
588
+ const flushPanTo = (x, y) => {
589
+ const dx = x - lastApplied.x;
590
+ const dy = y - lastApplied.y;
591
+ if (dx !== 0 || dy !== 0) host.panBy(dx, dy);
592
+ lastApplied = {
593
+ x,
594
+ y
595
+ };
596
+ dirty = false;
597
+ };
598
+ const flushPinch = (ax, ay, bx, by) => {
599
+ const cx = (ax + bx) / 2;
600
+ const cy = (ay + by) / 2;
601
+ const span = Math.hypot(ax - bx, ay - by);
602
+ const dx = cx - lastApplied.x;
603
+ const dy = cy - lastApplied.y;
604
+ if (dx !== 0 || dy !== 0) host.panBy(dx, dy);
605
+ if (zoomGestures && span > MIN_PINCH_SPAN && lastSpan > MIN_PINCH_SPAN && span !== lastSpan) host.zoomAround(vpt(cx, cy), span / lastSpan);
606
+ pushSample(cx, cy);
607
+ spanTrail.push({
608
+ t: performance.now(),
609
+ x: span,
610
+ y: 0
611
+ });
612
+ if (spanTrail.length > 16) spanTrail.shift();
613
+ lastApplied = {
614
+ x: cx,
615
+ y: cy
616
+ };
617
+ lastSpan = span;
618
+ dirty = false;
619
+ };
620
+ const tick = () => {
621
+ frame = 0;
622
+ if (mode === "pan" || mode === "mousedrag") {
623
+ const p = pointers.get(panId);
624
+ if (p && dirty) flushPanTo(p.x, p.y);
625
+ frame = requestAnimationFrame(tick);
626
+ } else if (mode === "pinch") {
627
+ const [a, b] = touches();
628
+ if (a && b && dirty) flushPinch(a.x, a.y, b.x, b.y);
629
+ frame = requestAnimationFrame(tick);
630
+ }
631
+ };
632
+ const ensureFrames = () => {
633
+ if (!frame) frame = requestAnimationFrame(tick);
634
+ };
635
+ const enterPinch = () => {
636
+ const [a, b] = touches();
637
+ if (!a || !b) return;
638
+ mode = "pinch";
639
+ lastApplied = {
640
+ x: (a.x + b.x) / 2,
641
+ y: (a.y + b.y) / 2
642
+ };
643
+ lastSpan = Math.hypot(a.x - b.x, a.y - b.y);
644
+ trail = [];
645
+ dirty = false;
646
+ ensureFrames();
647
+ };
648
+ const onLongPress = () => {
649
+ longPressTimer = null;
650
+ if (mode !== "pending" || !downEvent || !sink) return;
651
+ end();
652
+ mode = "tool";
653
+ touchToolGesture = true;
654
+ sink.longPress(downEvent);
655
+ };
656
+ const onDown = (e) => {
657
+ const kind = e.pointerType || "mouse";
658
+ if (kind === "touch") lastTouchAt = performance.now();
659
+ if (kind !== "touch") {
660
+ if (kind === "mouse" && e.button !== 0) return;
661
+ if (mode !== "idle") return;
662
+ pointers.set(e.pointerId, {
663
+ id: e.pointerId,
664
+ kind,
665
+ x: e.clientX,
666
+ y: e.clientY,
667
+ downX: e.clientX,
668
+ downY: e.clientY
669
+ });
670
+ if (sink) {
671
+ mode = "tool";
672
+ sink.down(e, clickCount(e));
673
+ } else {
674
+ mode = "mousedrag";
675
+ begin();
676
+ panId = e.pointerId;
677
+ lastApplied = {
678
+ x: e.clientX,
679
+ y: e.clientY
680
+ };
681
+ ensureFrames();
682
+ }
683
+ return;
684
+ }
685
+ switch (mode) {
686
+ case "idle": {
687
+ pointers.set(e.pointerId, {
688
+ id: e.pointerId,
689
+ kind,
690
+ x: e.clientX,
691
+ y: e.clientY,
692
+ downX: e.clientX,
693
+ downY: e.clientY
694
+ });
695
+ const moving = host.cameraInMotion();
696
+ if (!moving && sink?.claimsPoint?.(e)) {
697
+ mode = "tool";
698
+ touchToolGesture = true;
699
+ sink.down(e, 1);
700
+ break;
701
+ }
702
+ mode = "pending";
703
+ downEvent = e;
704
+ suppressTap = moving;
705
+ begin(true);
706
+ trail = [];
707
+ pushSample(e.clientX, e.clientY);
708
+ clearLongPress();
709
+ if (sink && !suppressTap) longPressTimer = setTimeout(onLongPress, LONG_PRESS_MS);
710
+ break;
711
+ }
712
+ case "pending":
713
+ case "pan":
714
+ pointers.set(e.pointerId, {
715
+ id: e.pointerId,
716
+ kind,
717
+ x: e.clientX,
718
+ y: e.clientY,
719
+ downX: e.clientX,
720
+ downY: e.clientY
721
+ });
722
+ clearLongPress();
723
+ enterPinch();
724
+ break;
725
+ case "tool":
726
+ if (!touchToolGesture) return;
727
+ sink?.cancel(e);
728
+ touchToolGesture = false;
729
+ pointers.set(e.pointerId, {
730
+ id: e.pointerId,
731
+ kind,
732
+ x: e.clientX,
733
+ y: e.clientY,
734
+ downX: e.clientX,
735
+ downY: e.clientY
736
+ });
737
+ begin(true);
738
+ enterPinch();
739
+ break;
740
+ case "pinch":
741
+ case "mousedrag": return;
742
+ }
743
+ };
744
+ const onWindowMove = (e) => {
745
+ const p = pointers.get(e.pointerId);
746
+ if (!p) return;
747
+ if (p.kind === "touch") lastTouchAt = performance.now();
748
+ p.x = e.clientX;
749
+ p.y = e.clientY;
750
+ switch (mode) {
751
+ case "pending":
752
+ pushSample(p.x, p.y);
753
+ if (Math.hypot(p.x - p.downX, p.y - p.downY) > SLOP) {
754
+ clearLongPress();
755
+ mode = "pan";
756
+ panId = p.id;
757
+ lastApplied = {
758
+ x: p.x,
759
+ y: p.y
760
+ };
761
+ ensureFrames();
762
+ }
763
+ break;
764
+ case "pan":
765
+ if (p.id !== panId) break;
766
+ if (!panArmed) {
767
+ const nowT = performance.now();
768
+ if (nowT < panGraceUntil) {
769
+ const dt = nowT - graceSample.t;
770
+ if (dt >= 8) {
771
+ const speed = Math.hypot(p.x - graceSample.x, p.y - graceSample.y) / dt;
772
+ graceSample = {
773
+ t: nowT,
774
+ x: p.x,
775
+ y: p.y
776
+ };
777
+ if (speed < SETTLE_SPEED) panGraceUntil = 0;
778
+ }
779
+ p.downX = p.x;
780
+ p.downY = p.y;
781
+ break;
782
+ }
783
+ if (Math.hypot(p.x - p.downX, p.y - p.downY) <= SLOP) break;
784
+ panArmed = true;
785
+ lastApplied = {
786
+ x: p.x,
787
+ y: p.y
788
+ };
789
+ trail = [];
790
+ spanTrail = [];
791
+ }
792
+ pushSample(p.x, p.y);
793
+ dirty = true;
794
+ break;
795
+ case "pinch":
796
+ dirty = true;
797
+ break;
798
+ case "tool":
799
+ sink?.move(e);
800
+ break;
801
+ case "mousedrag":
802
+ if (p.id === panId) dirty = true;
803
+ break;
804
+ case "idle": break;
805
+ }
806
+ };
807
+ const onHoverMove = (e) => {
808
+ if (mode === "idle" && sink) sink.hover(e);
809
+ };
810
+ const backToSingleFinger = () => {
811
+ const rest = touches();
812
+ if (rest.length !== 1) return false;
813
+ mode = "pan";
814
+ panId = rest[0].id;
815
+ panArmed = false;
816
+ panGraceUntil = performance.now() + PINCH_RELEASE_GRACE_MS;
817
+ graceSample = {
818
+ t: performance.now(),
819
+ x: rest[0].x,
820
+ y: rest[0].y
821
+ };
822
+ rest[0].downX = rest[0].x;
823
+ rest[0].downY = rest[0].y;
824
+ lastApplied = {
825
+ x: rest[0].x,
826
+ y: rest[0].y
827
+ };
828
+ trail = [];
829
+ dirty = false;
830
+ return true;
831
+ };
832
+ const onUp = (e) => {
833
+ const p = pointers.get(e.pointerId);
834
+ if (!p) return;
835
+ if (p.kind === "touch") lastTouchAt = performance.now();
836
+ pointers.delete(e.pointerId);
837
+ switch (mode) {
838
+ case "pending": {
839
+ clearLongPress();
840
+ end();
841
+ const now = performance.now();
842
+ const isDouble = now - lastTapT <= DOUBLE_TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) <= DOUBLE_TAP_RADIUS;
843
+ if (suppressTap) lastTapT = 0;
844
+ else if (zoomGestures && isDouble) {
845
+ lastTapT = 0;
846
+ host.doubleTapZoom(vpt(e.clientX, e.clientY));
847
+ } else {
848
+ lastTapT = now;
849
+ lastTapX = e.clientX;
850
+ lastTapY = e.clientY;
851
+ if (sink && downEvent) {
852
+ sink.down(downEvent, 1);
853
+ sink.up(e);
854
+ }
855
+ }
856
+ toIdle();
857
+ break;
858
+ }
859
+ case "pan":
860
+ if (!panArmed) {
861
+ const now = performance.now();
862
+ const v = computeReleaseVelocity(trail, now);
863
+ const vs = computeReleaseVelocity(spanTrail, now);
864
+ end();
865
+ if (v) {
866
+ const speed = Math.hypot(v.vx, v.vy);
867
+ if (speed >= FLING_MIN && speed > Math.abs(vs?.vx ?? 0)) host.fling(v.vx, v.vy);
868
+ }
869
+ toIdle();
870
+ break;
871
+ }
872
+ flushPanTo(e.clientX, e.clientY);
873
+ pushSample(e.clientX, e.clientY);
874
+ end();
875
+ maybeFling();
876
+ toIdle();
877
+ break;
878
+ case "pinch": {
879
+ const rest = touches();
880
+ if (rest.length === 1) {
881
+ mode = "pan";
882
+ panId = rest[0].id;
883
+ panArmed = false;
884
+ panGraceUntil = performance.now() + PINCH_RELEASE_GRACE_MS;
885
+ graceSample = {
886
+ t: performance.now(),
887
+ x: rest[0].x,
888
+ y: rest[0].y
889
+ };
890
+ rest[0].downX = rest[0].x;
891
+ rest[0].downY = rest[0].y;
892
+ lastApplied = {
893
+ x: rest[0].x,
894
+ y: rest[0].y
895
+ };
896
+ dirty = false;
897
+ break;
898
+ }
899
+ end();
900
+ maybeFling();
901
+ toIdle();
902
+ break;
903
+ }
904
+ case "tool":
905
+ sink?.up(e);
906
+ toIdle();
907
+ break;
908
+ case "mousedrag":
909
+ if (p.id !== panId) break;
910
+ flushPanTo(e.clientX, e.clientY);
911
+ end();
912
+ toIdle();
913
+ break;
914
+ case "idle": break;
915
+ }
916
+ };
917
+ const onCancel = (e) => {
918
+ if (!pointers.get(e.pointerId)) return;
919
+ pointers.delete(e.pointerId);
920
+ if (mode === "tool") {
921
+ sink?.cancel(e);
922
+ toIdle();
923
+ return;
924
+ }
925
+ if (mode === "pinch" && backToSingleFinger()) return;
926
+ end();
927
+ toIdle();
928
+ };
929
+ const onWheel = (e) => {
930
+ e.preventDefault();
931
+ if (zoomGestures && (e.ctrlKey || e.metaKey)) host.zoomAround(vpt(e.clientX, e.clientY), wheelZoom(e));
932
+ else {
933
+ const dx = e.shiftKey ? e.deltaY : e.deltaX;
934
+ const dy = e.shiftKey ? e.deltaX : e.deltaY;
935
+ host.panBy(-dx, -dy);
936
+ }
937
+ };
938
+ let lastScale = 1;
939
+ const onGestureStart = (e) => {
940
+ e.preventDefault();
941
+ lastScale = e.scale ?? 1;
942
+ };
943
+ const onGestureChange = (e) => {
944
+ e.preventDefault();
945
+ if (touches().length > 0 || performance.now() - lastTouchAt < 500) return;
946
+ const g = e;
947
+ const scale = g.scale ?? 1;
948
+ if (zoomGestures && scale > 0) host.zoomAround(vpt(g.clientX, g.clientY), scale / lastScale);
949
+ lastScale = scale;
950
+ };
951
+ el.addEventListener("pointerdown", onDown);
952
+ el.addEventListener("pointermove", onHoverMove);
953
+ window.addEventListener("pointermove", onWindowMove);
954
+ window.addEventListener("pointerup", onUp);
955
+ window.addEventListener("pointercancel", onCancel);
956
+ el.addEventListener("wheel", onWheel, { passive: false });
957
+ const hasGestureEvents = "GestureEvent" in window;
958
+ if (hasGestureEvents) {
959
+ el.addEventListener("gesturestart", onGestureStart);
960
+ el.addEventListener("gesturechange", onGestureChange);
961
+ el.addEventListener("gestureend", onGestureStart);
962
+ }
963
+ return () => {
964
+ el.removeEventListener("pointerdown", onDown);
965
+ el.removeEventListener("pointermove", onHoverMove);
966
+ window.removeEventListener("pointermove", onWindowMove);
967
+ window.removeEventListener("pointerup", onUp);
968
+ window.removeEventListener("pointercancel", onCancel);
969
+ el.removeEventListener("wheel", onWheel);
970
+ if (hasGestureEvents) {
971
+ el.removeEventListener("gesturestart", onGestureStart);
972
+ el.removeEventListener("gesturechange", onGestureChange);
973
+ el.removeEventListener("gestureend", onGestureStart);
974
+ }
975
+ clearLongPress();
976
+ stopFrames();
977
+ end();
978
+ };
979
+ }
980
+ //#endregion
981
+ //#region src/selection-handles.ts
982
+ /** Bind one handle element. Returns the detach fn. */
983
+ function attachSelectionHandle(el, options) {
984
+ let active = null;
985
+ const onMove = (e) => {
986
+ if (!active || e.pointerId !== active.pointerId) return;
987
+ active.session.move({
988
+ x: active.base.x + (e.clientX - active.baseClient.x),
989
+ y: active.base.y + (e.clientY - active.baseClient.y)
990
+ });
991
+ };
992
+ const onUp = (e) => {
993
+ if (!active || e.pointerId !== active.pointerId) return;
994
+ const s = active.session;
995
+ active = null;
996
+ s.end();
997
+ };
998
+ const onDown = (e) => {
999
+ if (active) return;
1000
+ const armed = options.arm();
1001
+ if (!armed) return;
1002
+ e.preventDefault();
1003
+ e.stopPropagation();
1004
+ try {
1005
+ el.setPointerCapture(e.pointerId);
1006
+ } catch {}
1007
+ active = {
1008
+ session: armed.session,
1009
+ base: armed.base,
1010
+ baseClient: {
1011
+ x: e.clientX,
1012
+ y: e.clientY
1013
+ },
1014
+ pointerId: e.pointerId
1015
+ };
1016
+ };
1017
+ el.addEventListener("pointerdown", onDown);
1018
+ el.addEventListener("pointermove", onMove);
1019
+ el.addEventListener("pointerup", onUp);
1020
+ el.addEventListener("pointercancel", onUp);
1021
+ return () => {
1022
+ el.removeEventListener("pointerdown", onDown);
1023
+ el.removeEventListener("pointermove", onMove);
1024
+ el.removeEventListener("pointerup", onUp);
1025
+ el.removeEventListener("pointercancel", onUp);
1026
+ active = null;
1027
+ };
1028
+ }
1029
+ //#endregion
1030
+ //#region src/stage-surface.ts
1031
+ /**
1032
+ * Stage surface binding — everything DOM about attaching one element to one
1033
+ * stage lens, shared by every framework adapter (React, Angular, …) so the
1034
+ * binding can never drift between them:
1035
+ *
1036
+ * • ResizeObserver → `setViewport` (the shell only reports; placement is the
1037
+ * stage plugin's job)
1038
+ * • device-pixel-ratio observation → `setDevicePixelRatio` (dppx changes
1039
+ * re-subscribe, since the media query's value itself moves)
1040
+ * • pointer-sample normalization — the ONE place a PointerEvent becomes a
1041
+ * page-resolved, source-stamped sample for the interaction hub
1042
+ * • the gesture controller (wheel, Safari trackpad gestures, synthesized
1043
+ * touch physics) from `./stage-gestures`
1044
+ *
1045
+ * The framework adapters keep only their reactive glue: refs/effects, page
1046
+ * surfaces, cursor-style subscription, overlay slots.
1047
+ *
1048
+ * Dependency note: like the gesture controller, this module speaks to the
1049
+ * stage and the hub through STRUCTURAL interfaces ({@link StageSurfaceHost},
1050
+ * {@link StageSurfaceHub}) — satisfied by `StageCapability` and
1051
+ * `InteractionCapability`, imported by neither. @embedpdf/web stays free of
1052
+ * plugin imports, per the layering law.
1053
+ *
1054
+ * Browser-only by nature (ResizeObserver, matchMedia): call from a mounted
1055
+ * effect / browser platform check, exactly like the gesture controller.
1056
+ */
1057
+ /** Attach the full surface binding. Returns the detach fn. */
1058
+ function createStageSurface(el, stage, options = {}) {
1059
+ const hub = options.hub ?? null;
1060
+ const cleanups = [];
1061
+ const setViewport = () => stage.setViewport({
1062
+ width: el.clientWidth,
1063
+ height: el.clientHeight
1064
+ });
1065
+ const ro = new ResizeObserver(setViewport);
1066
+ ro.observe(el);
1067
+ setViewport();
1068
+ cleanups.push(() => ro.disconnect());
1069
+ let mq = null;
1070
+ const reportDpr = () => {
1071
+ stage.setDevicePixelRatio(window.devicePixelRatio || 1);
1072
+ mq?.removeEventListener("change", reportDpr);
1073
+ mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
1074
+ mq.addEventListener("change", reportDpr);
1075
+ };
1076
+ reportDpr();
1077
+ cleanups.push(() => mq?.removeEventListener("change", reportDpr));
1078
+ const sampleOf = (phase, e, clickCount = 1, gesture) => {
1079
+ const r = el.getBoundingClientRect();
1080
+ const viewport = {
1081
+ x: e.clientX - r.left,
1082
+ y: e.clientY - r.top
1083
+ };
1084
+ return {
1085
+ phase,
1086
+ viewport,
1087
+ page: stage.pageAt(viewport) ?? void 0,
1088
+ project: (pon) => stage.pointOnPage(pon, viewport),
1089
+ modifiers: {
1090
+ shift: e.shiftKey,
1091
+ alt: e.altKey,
1092
+ ctrl: e.ctrlKey,
1093
+ meta: e.metaKey
1094
+ },
1095
+ clickCount,
1096
+ pointerType: e.pointerType || "mouse",
1097
+ ...gesture ? { gesture } : {},
1098
+ ...options.source ? { source: options.source } : {}
1099
+ };
1100
+ };
1101
+ const forward = (phase, e, clickCount = 1, gesture) => {
1102
+ hub?.dispatch(sampleOf(phase, e, clickCount, gesture));
1103
+ };
1104
+ const sink = hub ? {
1105
+ down: (e, clickCount) => forward("down", e, clickCount),
1106
+ move: (e) => forward("move", e),
1107
+ up: (e) => forward("up", e),
1108
+ cancel: (e) => forward("cancel", e),
1109
+ hover: (e) => forward("move", e),
1110
+ longPress: (e) => forward("down", e, 2, "long-press"),
1111
+ claimsPoint: (e) => !!hub.activeTool().touchDirect || hub.wouldClaimTouch(sampleOf("down", e))
1112
+ } : null;
1113
+ cleanups.push(createStageGestureController(el, stage, {
1114
+ zoomGestures: options.zoomGestures,
1115
+ wheelZoomFactor: options.wheelZoomFactor,
1116
+ sink
1117
+ }));
1118
+ return () => {
1119
+ for (const fn of cleanups) fn();
1120
+ };
1121
+ }
1122
+ //#endregion
1123
+ exports.attachSelectionHandle = attachSelectionHandle;
1124
+ exports.bindPaintedImage = bindPaintedImage;
1125
+ exports.computeReleaseVelocity = computeReleaseVelocity;
1126
+ exports.copySelection = copySelection;
1127
+ exports.createDefaultActionsUiAdapter = createDefaultActionsUiAdapter;
1128
+ exports.createStageGestureController = createStageGestureController;
1129
+ exports.createStageSurface = createStageSurface;
1130
+ exports.observeClientGeometry = observeClientGeometry;
108
1131
  exports.pickFile = pickFile;
109
1132
  exports.pickImageFile = pickImageFile;
1133
+ exports.positionAnchoredRect = positionAnchoredRect;
1134
+ exports.projectAnchoredTarget = projectAnchoredTarget;
110
1135
  exports.sanitizeExternalUri = sanitizeExternalUri;
111
1136
  exports.saveFile = saveFile;
112
1137
  exports.svgCursor = svgCursor;
1138
+ exports.vibrationFeedback = vibrationFeedback;
1139
+ exports.wheelZoomFactor = wheelZoomFactor;
1140
+ exports.wireSelectionClipboard = wireSelectionClipboard;
1141
+ exports.wkFeedback = wkFeedback;
113
1142
 
114
1143
  //# sourceMappingURL=index.cjs.map