@embedpdf/web 3.0.0-next.6 → 3.0.0-next.8

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
@@ -281,8 +281,801 @@ function bindPaintedImage(image, source, callbacks) {
281
281
  };
282
282
  }
283
283
  //#endregion
284
+ //#region src/feedback.ts
285
+ const vibrate = (pattern) => {
286
+ if (typeof navigator !== "undefined") navigator.vibrate?.(pattern);
287
+ };
288
+ /**
289
+ * The default web provider: the Vibration API, mapped onto the three haptic
290
+ * families (selection = the shortest tick; impact scales with weight; notify
291
+ * uses patterns). A no-op wherever vibration is unsupported — notably iOS
292
+ * Safari — so it is always safe to install.
293
+ */
294
+ const vibrationFeedback = {
295
+ selection: () => vibrate(8),
296
+ impact: (weight = "light") => vibrate(weight === "heavy" ? 30 : weight === "medium" ? 20 : 10),
297
+ notify: (kind) => vibrate(kind === "error" ? [
298
+ 12,
299
+ 60,
300
+ 12,
301
+ 60,
302
+ 12
303
+ ] : kind === "warning" ? [
304
+ 15,
305
+ 70,
306
+ 15
307
+ ] : [
308
+ 10,
309
+ 50,
310
+ 10
311
+ ])
312
+ };
313
+ /**
314
+ * A provider for native iOS shells: forwards each call to a WKWebView script
315
+ * message handler the HOST APP installed under `handlerName` — an explicit
316
+ * contract between the embedder and their own app, never a global this library
317
+ * sniffs on its own. The message shape is
318
+ * `{ family: 'selection' } | { family: 'impact', weight } | { family: 'notify', kind }`;
319
+ * the native side maps families onto the matching `UIFeedbackGenerator`.
320
+ * Calls fall back to `fallback` (default: {@link vibrationFeedback}) when the
321
+ * handler is absent, so one provider serves a hybrid web/app deployment.
322
+ */
323
+ const wkFeedback = (handlerName, fallback = vibrationFeedback) => {
324
+ const handler = () => {
325
+ if (typeof window === "undefined") return void 0;
326
+ return window.webkit?.messageHandlers?.[handlerName];
327
+ };
328
+ return {
329
+ selection: () => {
330
+ const h = handler();
331
+ if (h) h.postMessage({ family: "selection" });
332
+ else fallback.selection();
333
+ },
334
+ impact: (weight = "light") => {
335
+ const h = handler();
336
+ if (h) h.postMessage({
337
+ family: "impact",
338
+ weight
339
+ });
340
+ else fallback.impact(weight);
341
+ },
342
+ notify: (kind) => {
343
+ const h = handler();
344
+ if (h) h.postMessage({
345
+ family: "notify",
346
+ kind
347
+ });
348
+ else fallback.notify(kind);
349
+ }
350
+ };
351
+ };
352
+ //#endregion
353
+ //#region src/wheel.ts
354
+ /** The per-notch step for discrete wheels — the zoom buttons' ratio. */
355
+ const NOTCH = 1.2;
356
+ /** Zoom factor for a ctrl/cmd wheel event (the caller decides zoom vs pan). */
357
+ function wheelZoomFactor(e) {
358
+ if (e.deltaMode !== 0) return Math.pow(NOTCH, -Math.sign(e.deltaY));
359
+ if (e.ctrlKey) {
360
+ if (Math.abs(e.deltaY) >= 100) return Math.pow(NOTCH, -Math.sign(e.deltaY));
361
+ return Math.exp(-e.deltaY / 100);
362
+ }
363
+ return Math.exp(-e.deltaY * .0015);
364
+ }
365
+ //#endregion
366
+ //#region src/stage-gestures.ts
367
+ /**
368
+ * Stage gesture controller — the ONE DOM input binding for a Stage surface,
369
+ * shared by every framework adapter (React, Angular, …) so the feel can never
370
+ * drift between them.
371
+ *
372
+ * The premise (see the stage plugin's camera doctrine): desktop inputs arrive
373
+ * with physics already applied by the OS — momentum lives in the wheel stream,
374
+ * a trackpad pinch is one pre-arbitrated gesture stream. Touch arrives RAW:
375
+ * with `touch-action: none` the platform scroller is out of the loop, so the
376
+ * ballistics (velocity → fling) and the arbitration (is this contact a scroll,
377
+ * a pinch, a tap, or a tool gesture?) must be synthesized here.
378
+ *
379
+ * Arbitration is MODALITY-AWARE:
380
+ * - touch — navigation-first: one finger pans (whatever tool is armed), two
381
+ * fingers pinch-zoom around their centroid, release velocity flings, a tap
382
+ * forwards as a click, a double-tap zoom-toggles, a long-press hands the
383
+ * gesture to the interaction hub (text selection), and a second finger
384
+ * landing mid-tool-gesture CANCELS it into a pinch (the Notes/Procreate
385
+ * convention).
386
+ * - mouse/pen — tool-first, exactly the pre-existing behavior: with a hub,
387
+ * every down/move/up forwards (pan is the pan tool's job); without one,
388
+ * dragging pans. Wheel and Safari-trackpad gesture events are unchanged.
389
+ *
390
+ * Camera writes are rAF-COALESCED: pointer events only update gesture state;
391
+ * one animation-frame tick applies at most one pan and one zoom per frame,
392
+ * inside the host's begin/endGesture transaction. Events may arrive at 120 Hz;
393
+ * the camera moves at display rate.
394
+ *
395
+ * Dependency note: this module speaks to the stage through the STRUCTURAL
396
+ * {@link StageGestureHost} interface (satisfied by `StageCapability`) and to
397
+ * the interaction hub through {@link StageGestureSink} (a closure the adapter
398
+ * builds) — @embedpdf/web stays free of plugin imports, per the layering law.
399
+ */
400
+ /**
401
+ * Release velocity from a trail of pointer samples: the mean velocity over the
402
+ * trailing `windowMs` (first-to-last inside the window). Null when the trail is
403
+ * too thin or too stale to trust — the standard "held still, then let go"
404
+ * case, which must NOT fling. Pure; exported for tests.
405
+ */
406
+ function computeReleaseVelocity(samples, now, windowMs = 100) {
407
+ let firstIdx = -1;
408
+ for (let i = 0; i < samples.length; i++) if (now - samples[i].t <= windowMs) {
409
+ firstIdx = i;
410
+ break;
411
+ }
412
+ if (firstIdx < 0 || firstIdx === samples.length - 1) return null;
413
+ const first = samples[firstIdx];
414
+ const last = samples[samples.length - 1];
415
+ const dt = last.t - first.t;
416
+ if (dt < 10) return null;
417
+ return {
418
+ vx: (last.x - first.x) / dt * 1e3,
419
+ vy: (last.y - first.y) / dt * 1e3
420
+ };
421
+ }
422
+ /** Attach the gesture controller to a Stage container. Returns the detach fn. */
423
+ function createStageGestureController(el, host, options) {
424
+ const zoomGestures = options.zoomGestures ?? true;
425
+ const wheelZoom = options.wheelZoomFactor ?? wheelZoomFactor;
426
+ const sink = options.sink ?? null;
427
+ const LONG_PRESS_MS = options.longPressMs ?? 450;
428
+ const SLOP = options.tapSlopPx ?? 10;
429
+ const DOUBLE_TAP_MS = options.doubleTapMs ?? 300;
430
+ const DOUBLE_TAP_RADIUS = 25;
431
+ const FLING_MIN = options.flingMinVelocity ?? 50;
432
+ const MIN_PINCH_SPAN = 20;
433
+ const PINCH_RELEASE_GRACE_MS = 160;
434
+ const SETTLE_SPEED = .12;
435
+ const pointers = /* @__PURE__ */ new Map();
436
+ let mode = "idle";
437
+ let began = false;
438
+ let suppressTap = false;
439
+ let touchToolGesture = false;
440
+ let downEvent = null;
441
+ let longPressTimer = null;
442
+ let panId = -1;
443
+ let panArmed = true;
444
+ let panGraceUntil = 0;
445
+ let graceSample = {
446
+ t: 0,
447
+ x: 0,
448
+ y: 0
449
+ };
450
+ let lastTouchAt = -Infinity;
451
+ let frame = 0;
452
+ let dirty = false;
453
+ let lastApplied = {
454
+ x: 0,
455
+ y: 0
456
+ };
457
+ let lastSpan = 0;
458
+ let trail = [];
459
+ let spanTrail = [];
460
+ let lastTapT = 0;
461
+ let lastTapX = 0;
462
+ let lastTapY = 0;
463
+ let mLast = 0;
464
+ let mX = 0;
465
+ let mY = 0;
466
+ let mCount = 0;
467
+ const clickCount = (e) => {
468
+ const now = Date.now();
469
+ mCount = now - mLast <= 400 && Math.hypot(e.clientX - mX, e.clientY - mY) <= 6 ? mCount + 1 : 1;
470
+ mLast = now;
471
+ mX = e.clientX;
472
+ mY = e.clientY;
473
+ return mCount;
474
+ };
475
+ const vpt = (clientX, clientY) => {
476
+ const r = el.getBoundingClientRect();
477
+ return {
478
+ x: clientX - r.left,
479
+ y: clientY - r.top
480
+ };
481
+ };
482
+ const touches = () => {
483
+ const out = [];
484
+ pointers.forEach((p) => {
485
+ if (p.kind === "touch") out.push(p);
486
+ });
487
+ return out;
488
+ };
489
+ const clearLongPress = () => {
490
+ if (longPressTimer !== null) {
491
+ clearTimeout(longPressTimer);
492
+ longPressTimer = null;
493
+ }
494
+ };
495
+ const stopFrames = () => {
496
+ if (frame) {
497
+ cancelAnimationFrame(frame);
498
+ frame = 0;
499
+ }
500
+ };
501
+ const begin = (elastic = false) => {
502
+ if (!began) {
503
+ began = true;
504
+ host.beginGesture(elastic ? { elastic: true } : void 0);
505
+ }
506
+ };
507
+ const end = () => {
508
+ if (began) {
509
+ began = false;
510
+ host.endGesture();
511
+ }
512
+ };
513
+ const toIdle = () => {
514
+ mode = "idle";
515
+ downEvent = null;
516
+ touchToolGesture = false;
517
+ trail = [];
518
+ spanTrail = [];
519
+ dirty = false;
520
+ panId = -1;
521
+ panArmed = true;
522
+ panGraceUntil = 0;
523
+ clearLongPress();
524
+ stopFrames();
525
+ };
526
+ const pushSample = (x, y) => {
527
+ trail.push({
528
+ t: performance.now(),
529
+ x,
530
+ y
531
+ });
532
+ if (trail.length > 16) trail.shift();
533
+ };
534
+ const maybeFling = () => {
535
+ const v = computeReleaseVelocity(trail, performance.now());
536
+ if (v && Math.hypot(v.vx, v.vy) >= FLING_MIN) host.fling(v.vx, v.vy);
537
+ };
538
+ const flushPanTo = (x, y) => {
539
+ const dx = x - lastApplied.x;
540
+ const dy = y - lastApplied.y;
541
+ if (dx !== 0 || dy !== 0) host.panBy(dx, dy);
542
+ lastApplied = {
543
+ x,
544
+ y
545
+ };
546
+ dirty = false;
547
+ };
548
+ const flushPinch = (ax, ay, bx, by) => {
549
+ const cx = (ax + bx) / 2;
550
+ const cy = (ay + by) / 2;
551
+ const span = Math.hypot(ax - bx, ay - by);
552
+ const dx = cx - lastApplied.x;
553
+ const dy = cy - lastApplied.y;
554
+ if (dx !== 0 || dy !== 0) host.panBy(dx, dy);
555
+ if (zoomGestures && span > MIN_PINCH_SPAN && lastSpan > MIN_PINCH_SPAN && span !== lastSpan) host.zoomAround(vpt(cx, cy), span / lastSpan);
556
+ pushSample(cx, cy);
557
+ spanTrail.push({
558
+ t: performance.now(),
559
+ x: span,
560
+ y: 0
561
+ });
562
+ if (spanTrail.length > 16) spanTrail.shift();
563
+ lastApplied = {
564
+ x: cx,
565
+ y: cy
566
+ };
567
+ lastSpan = span;
568
+ dirty = false;
569
+ };
570
+ const tick = () => {
571
+ frame = 0;
572
+ if (mode === "pan" || mode === "mousedrag") {
573
+ const p = pointers.get(panId);
574
+ if (p && dirty) flushPanTo(p.x, p.y);
575
+ frame = requestAnimationFrame(tick);
576
+ } else if (mode === "pinch") {
577
+ const [a, b] = touches();
578
+ if (a && b && dirty) flushPinch(a.x, a.y, b.x, b.y);
579
+ frame = requestAnimationFrame(tick);
580
+ }
581
+ };
582
+ const ensureFrames = () => {
583
+ if (!frame) frame = requestAnimationFrame(tick);
584
+ };
585
+ const enterPinch = () => {
586
+ const [a, b] = touches();
587
+ if (!a || !b) return;
588
+ mode = "pinch";
589
+ lastApplied = {
590
+ x: (a.x + b.x) / 2,
591
+ y: (a.y + b.y) / 2
592
+ };
593
+ lastSpan = Math.hypot(a.x - b.x, a.y - b.y);
594
+ trail = [];
595
+ dirty = false;
596
+ ensureFrames();
597
+ };
598
+ const onLongPress = () => {
599
+ longPressTimer = null;
600
+ if (mode !== "pending" || !downEvent || !sink) return;
601
+ end();
602
+ mode = "tool";
603
+ touchToolGesture = true;
604
+ sink.longPress(downEvent);
605
+ };
606
+ const onDown = (e) => {
607
+ const kind = e.pointerType || "mouse";
608
+ if (kind === "touch") lastTouchAt = performance.now();
609
+ if (kind !== "touch") {
610
+ if (kind === "mouse" && e.button !== 0) return;
611
+ if (mode !== "idle") return;
612
+ pointers.set(e.pointerId, {
613
+ id: e.pointerId,
614
+ kind,
615
+ x: e.clientX,
616
+ y: e.clientY,
617
+ downX: e.clientX,
618
+ downY: e.clientY
619
+ });
620
+ if (sink) {
621
+ mode = "tool";
622
+ sink.down(e, clickCount(e));
623
+ } else {
624
+ mode = "mousedrag";
625
+ begin();
626
+ panId = e.pointerId;
627
+ lastApplied = {
628
+ x: e.clientX,
629
+ y: e.clientY
630
+ };
631
+ ensureFrames();
632
+ }
633
+ return;
634
+ }
635
+ switch (mode) {
636
+ case "idle": {
637
+ pointers.set(e.pointerId, {
638
+ id: e.pointerId,
639
+ kind,
640
+ x: e.clientX,
641
+ y: e.clientY,
642
+ downX: e.clientX,
643
+ downY: e.clientY
644
+ });
645
+ const moving = host.cameraInMotion();
646
+ if (!moving && sink?.claimsPoint?.(e)) {
647
+ mode = "tool";
648
+ touchToolGesture = true;
649
+ sink.down(e, 1);
650
+ break;
651
+ }
652
+ mode = "pending";
653
+ downEvent = e;
654
+ suppressTap = moving;
655
+ begin(true);
656
+ trail = [];
657
+ pushSample(e.clientX, e.clientY);
658
+ clearLongPress();
659
+ if (sink && !suppressTap) longPressTimer = setTimeout(onLongPress, LONG_PRESS_MS);
660
+ break;
661
+ }
662
+ case "pending":
663
+ case "pan":
664
+ pointers.set(e.pointerId, {
665
+ id: e.pointerId,
666
+ kind,
667
+ x: e.clientX,
668
+ y: e.clientY,
669
+ downX: e.clientX,
670
+ downY: e.clientY
671
+ });
672
+ clearLongPress();
673
+ enterPinch();
674
+ break;
675
+ case "tool":
676
+ if (!touchToolGesture) return;
677
+ sink?.cancel(e);
678
+ touchToolGesture = false;
679
+ pointers.set(e.pointerId, {
680
+ id: e.pointerId,
681
+ kind,
682
+ x: e.clientX,
683
+ y: e.clientY,
684
+ downX: e.clientX,
685
+ downY: e.clientY
686
+ });
687
+ begin(true);
688
+ enterPinch();
689
+ break;
690
+ case "pinch":
691
+ case "mousedrag": return;
692
+ }
693
+ };
694
+ const onWindowMove = (e) => {
695
+ const p = pointers.get(e.pointerId);
696
+ if (!p) return;
697
+ if (p.kind === "touch") lastTouchAt = performance.now();
698
+ p.x = e.clientX;
699
+ p.y = e.clientY;
700
+ switch (mode) {
701
+ case "pending":
702
+ pushSample(p.x, p.y);
703
+ if (Math.hypot(p.x - p.downX, p.y - p.downY) > SLOP) {
704
+ clearLongPress();
705
+ mode = "pan";
706
+ panId = p.id;
707
+ lastApplied = {
708
+ x: p.x,
709
+ y: p.y
710
+ };
711
+ ensureFrames();
712
+ }
713
+ break;
714
+ case "pan":
715
+ if (p.id !== panId) break;
716
+ if (!panArmed) {
717
+ const nowT = performance.now();
718
+ if (nowT < panGraceUntil) {
719
+ const dt = nowT - graceSample.t;
720
+ if (dt >= 8) {
721
+ const speed = Math.hypot(p.x - graceSample.x, p.y - graceSample.y) / dt;
722
+ graceSample = {
723
+ t: nowT,
724
+ x: p.x,
725
+ y: p.y
726
+ };
727
+ if (speed < SETTLE_SPEED) panGraceUntil = 0;
728
+ }
729
+ p.downX = p.x;
730
+ p.downY = p.y;
731
+ break;
732
+ }
733
+ if (Math.hypot(p.x - p.downX, p.y - p.downY) <= SLOP) break;
734
+ panArmed = true;
735
+ lastApplied = {
736
+ x: p.x,
737
+ y: p.y
738
+ };
739
+ trail = [];
740
+ spanTrail = [];
741
+ }
742
+ pushSample(p.x, p.y);
743
+ dirty = true;
744
+ break;
745
+ case "pinch":
746
+ dirty = true;
747
+ break;
748
+ case "tool":
749
+ sink?.move(e);
750
+ break;
751
+ case "mousedrag":
752
+ if (p.id === panId) dirty = true;
753
+ break;
754
+ case "idle": break;
755
+ }
756
+ };
757
+ const onHoverMove = (e) => {
758
+ if (mode === "idle" && sink) sink.hover(e);
759
+ };
760
+ const backToSingleFinger = () => {
761
+ const rest = touches();
762
+ if (rest.length !== 1) return false;
763
+ mode = "pan";
764
+ panId = rest[0].id;
765
+ panArmed = false;
766
+ panGraceUntil = performance.now() + PINCH_RELEASE_GRACE_MS;
767
+ graceSample = {
768
+ t: performance.now(),
769
+ x: rest[0].x,
770
+ y: rest[0].y
771
+ };
772
+ rest[0].downX = rest[0].x;
773
+ rest[0].downY = rest[0].y;
774
+ lastApplied = {
775
+ x: rest[0].x,
776
+ y: rest[0].y
777
+ };
778
+ trail = [];
779
+ dirty = false;
780
+ return true;
781
+ };
782
+ const onUp = (e) => {
783
+ const p = pointers.get(e.pointerId);
784
+ if (!p) return;
785
+ if (p.kind === "touch") lastTouchAt = performance.now();
786
+ pointers.delete(e.pointerId);
787
+ switch (mode) {
788
+ case "pending": {
789
+ clearLongPress();
790
+ end();
791
+ const now = performance.now();
792
+ const isDouble = now - lastTapT <= DOUBLE_TAP_MS && Math.hypot(e.clientX - lastTapX, e.clientY - lastTapY) <= DOUBLE_TAP_RADIUS;
793
+ if (suppressTap) lastTapT = 0;
794
+ else if (zoomGestures && isDouble) {
795
+ lastTapT = 0;
796
+ host.doubleTapZoom(vpt(e.clientX, e.clientY));
797
+ } else {
798
+ lastTapT = now;
799
+ lastTapX = e.clientX;
800
+ lastTapY = e.clientY;
801
+ if (sink && downEvent) {
802
+ sink.down(downEvent, 1);
803
+ sink.up(e);
804
+ }
805
+ }
806
+ toIdle();
807
+ break;
808
+ }
809
+ case "pan":
810
+ if (!panArmed) {
811
+ const now = performance.now();
812
+ const v = computeReleaseVelocity(trail, now);
813
+ const vs = computeReleaseVelocity(spanTrail, now);
814
+ end();
815
+ if (v) {
816
+ const speed = Math.hypot(v.vx, v.vy);
817
+ if (speed >= FLING_MIN && speed > Math.abs(vs?.vx ?? 0)) host.fling(v.vx, v.vy);
818
+ }
819
+ toIdle();
820
+ break;
821
+ }
822
+ flushPanTo(e.clientX, e.clientY);
823
+ pushSample(e.clientX, e.clientY);
824
+ end();
825
+ maybeFling();
826
+ toIdle();
827
+ break;
828
+ case "pinch": {
829
+ const rest = touches();
830
+ if (rest.length === 1) {
831
+ mode = "pan";
832
+ panId = rest[0].id;
833
+ panArmed = false;
834
+ panGraceUntil = performance.now() + PINCH_RELEASE_GRACE_MS;
835
+ graceSample = {
836
+ t: performance.now(),
837
+ x: rest[0].x,
838
+ y: rest[0].y
839
+ };
840
+ rest[0].downX = rest[0].x;
841
+ rest[0].downY = rest[0].y;
842
+ lastApplied = {
843
+ x: rest[0].x,
844
+ y: rest[0].y
845
+ };
846
+ dirty = false;
847
+ break;
848
+ }
849
+ end();
850
+ maybeFling();
851
+ toIdle();
852
+ break;
853
+ }
854
+ case "tool":
855
+ sink?.up(e);
856
+ toIdle();
857
+ break;
858
+ case "mousedrag":
859
+ if (p.id !== panId) break;
860
+ flushPanTo(e.clientX, e.clientY);
861
+ end();
862
+ toIdle();
863
+ break;
864
+ case "idle": break;
865
+ }
866
+ };
867
+ const onCancel = (e) => {
868
+ if (!pointers.get(e.pointerId)) return;
869
+ pointers.delete(e.pointerId);
870
+ if (mode === "tool") {
871
+ sink?.cancel(e);
872
+ toIdle();
873
+ return;
874
+ }
875
+ if (mode === "pinch" && backToSingleFinger()) return;
876
+ end();
877
+ toIdle();
878
+ };
879
+ const onWheel = (e) => {
880
+ e.preventDefault();
881
+ if (zoomGestures && (e.ctrlKey || e.metaKey)) host.zoomAround(vpt(e.clientX, e.clientY), wheelZoom(e));
882
+ else {
883
+ const dx = e.shiftKey ? e.deltaY : e.deltaX;
884
+ const dy = e.shiftKey ? e.deltaX : e.deltaY;
885
+ host.panBy(-dx, -dy);
886
+ }
887
+ };
888
+ let lastScale = 1;
889
+ const onGestureStart = (e) => {
890
+ e.preventDefault();
891
+ lastScale = e.scale ?? 1;
892
+ };
893
+ const onGestureChange = (e) => {
894
+ e.preventDefault();
895
+ if (touches().length > 0 || performance.now() - lastTouchAt < 500) return;
896
+ const g = e;
897
+ const scale = g.scale ?? 1;
898
+ if (zoomGestures && scale > 0) host.zoomAround(vpt(g.clientX, g.clientY), scale / lastScale);
899
+ lastScale = scale;
900
+ };
901
+ el.addEventListener("pointerdown", onDown);
902
+ el.addEventListener("pointermove", onHoverMove);
903
+ window.addEventListener("pointermove", onWindowMove);
904
+ window.addEventListener("pointerup", onUp);
905
+ window.addEventListener("pointercancel", onCancel);
906
+ el.addEventListener("wheel", onWheel, { passive: false });
907
+ const hasGestureEvents = "GestureEvent" in window;
908
+ if (hasGestureEvents) {
909
+ el.addEventListener("gesturestart", onGestureStart);
910
+ el.addEventListener("gesturechange", onGestureChange);
911
+ el.addEventListener("gestureend", onGestureStart);
912
+ }
913
+ return () => {
914
+ el.removeEventListener("pointerdown", onDown);
915
+ el.removeEventListener("pointermove", onHoverMove);
916
+ window.removeEventListener("pointermove", onWindowMove);
917
+ window.removeEventListener("pointerup", onUp);
918
+ window.removeEventListener("pointercancel", onCancel);
919
+ el.removeEventListener("wheel", onWheel);
920
+ if (hasGestureEvents) {
921
+ el.removeEventListener("gesturestart", onGestureStart);
922
+ el.removeEventListener("gesturechange", onGestureChange);
923
+ el.removeEventListener("gestureend", onGestureStart);
924
+ }
925
+ clearLongPress();
926
+ stopFrames();
927
+ end();
928
+ };
929
+ }
930
+ //#endregion
931
+ //#region src/selection-handles.ts
932
+ /** Bind one handle element. Returns the detach fn. */
933
+ function attachSelectionHandle(el, options) {
934
+ let active = null;
935
+ const onMove = (e) => {
936
+ if (!active || e.pointerId !== active.pointerId) return;
937
+ active.session.move({
938
+ x: active.base.x + (e.clientX - active.baseClient.x),
939
+ y: active.base.y + (e.clientY - active.baseClient.y)
940
+ });
941
+ };
942
+ const onUp = (e) => {
943
+ if (!active || e.pointerId !== active.pointerId) return;
944
+ const s = active.session;
945
+ active = null;
946
+ s.end();
947
+ };
948
+ const onDown = (e) => {
949
+ if (active) return;
950
+ const armed = options.arm();
951
+ if (!armed) return;
952
+ e.preventDefault();
953
+ e.stopPropagation();
954
+ try {
955
+ el.setPointerCapture(e.pointerId);
956
+ } catch {}
957
+ active = {
958
+ session: armed.session,
959
+ base: armed.base,
960
+ baseClient: {
961
+ x: e.clientX,
962
+ y: e.clientY
963
+ },
964
+ pointerId: e.pointerId
965
+ };
966
+ };
967
+ el.addEventListener("pointerdown", onDown);
968
+ el.addEventListener("pointermove", onMove);
969
+ el.addEventListener("pointerup", onUp);
970
+ el.addEventListener("pointercancel", onUp);
971
+ return () => {
972
+ el.removeEventListener("pointerdown", onDown);
973
+ el.removeEventListener("pointermove", onMove);
974
+ el.removeEventListener("pointerup", onUp);
975
+ el.removeEventListener("pointercancel", onUp);
976
+ active = null;
977
+ };
978
+ }
979
+ //#endregion
980
+ //#region src/stage-surface.ts
981
+ /**
982
+ * Stage surface binding — everything DOM about attaching one element to one
983
+ * stage lens, shared by every framework adapter (React, Angular, …) so the
984
+ * binding can never drift between them:
985
+ *
986
+ * • ResizeObserver → `setViewport` (the shell only reports; placement is the
987
+ * stage plugin's job)
988
+ * • device-pixel-ratio observation → `setDevicePixelRatio` (dppx changes
989
+ * re-subscribe, since the media query's value itself moves)
990
+ * • pointer-sample normalization — the ONE place a PointerEvent becomes a
991
+ * page-resolved, source-stamped sample for the interaction hub
992
+ * • the gesture controller (wheel, Safari trackpad gestures, synthesized
993
+ * touch physics) from `./stage-gestures`
994
+ *
995
+ * The framework adapters keep only their reactive glue: refs/effects, page
996
+ * surfaces, cursor-style subscription, overlay slots.
997
+ *
998
+ * Dependency note: like the gesture controller, this module speaks to the
999
+ * stage and the hub through STRUCTURAL interfaces ({@link StageSurfaceHost},
1000
+ * {@link StageSurfaceHub}) — satisfied by `StageCapability` and
1001
+ * `InteractionCapability`, imported by neither. @embedpdf/web stays free of
1002
+ * plugin imports, per the layering law.
1003
+ *
1004
+ * Browser-only by nature (ResizeObserver, matchMedia): call from a mounted
1005
+ * effect / browser platform check, exactly like the gesture controller.
1006
+ */
1007
+ /** Attach the full surface binding. Returns the detach fn. */
1008
+ function createStageSurface(el, stage, options = {}) {
1009
+ const hub = options.hub ?? null;
1010
+ const cleanups = [];
1011
+ const setViewport = () => stage.setViewport({
1012
+ width: el.clientWidth,
1013
+ height: el.clientHeight
1014
+ });
1015
+ const ro = new ResizeObserver(setViewport);
1016
+ ro.observe(el);
1017
+ setViewport();
1018
+ cleanups.push(() => ro.disconnect());
1019
+ let mq = null;
1020
+ const reportDpr = () => {
1021
+ stage.setDevicePixelRatio(window.devicePixelRatio || 1);
1022
+ mq?.removeEventListener("change", reportDpr);
1023
+ mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
1024
+ mq.addEventListener("change", reportDpr);
1025
+ };
1026
+ reportDpr();
1027
+ cleanups.push(() => mq?.removeEventListener("change", reportDpr));
1028
+ const sampleOf = (phase, e, clickCount = 1, gesture) => {
1029
+ const r = el.getBoundingClientRect();
1030
+ const viewport = {
1031
+ x: e.clientX - r.left,
1032
+ y: e.clientY - r.top
1033
+ };
1034
+ return {
1035
+ phase,
1036
+ viewport,
1037
+ page: stage.pageAt(viewport) ?? void 0,
1038
+ project: (pon) => stage.pointOnPage(pon, viewport),
1039
+ modifiers: {
1040
+ shift: e.shiftKey,
1041
+ alt: e.altKey,
1042
+ ctrl: e.ctrlKey,
1043
+ meta: e.metaKey
1044
+ },
1045
+ clickCount,
1046
+ pointerType: e.pointerType || "mouse",
1047
+ ...gesture ? { gesture } : {},
1048
+ ...options.source ? { source: options.source } : {}
1049
+ };
1050
+ };
1051
+ const forward = (phase, e, clickCount = 1, gesture) => {
1052
+ hub?.dispatch(sampleOf(phase, e, clickCount, gesture));
1053
+ };
1054
+ const sink = hub ? {
1055
+ down: (e, clickCount) => forward("down", e, clickCount),
1056
+ move: (e) => forward("move", e),
1057
+ up: (e) => forward("up", e),
1058
+ cancel: (e) => forward("cancel", e),
1059
+ hover: (e) => forward("move", e),
1060
+ longPress: (e) => forward("down", e, 2, "long-press"),
1061
+ claimsPoint: (e) => !!hub.activeTool().touchDirect || hub.wouldClaimTouch(sampleOf("down", e))
1062
+ } : null;
1063
+ cleanups.push(createStageGestureController(el, stage, {
1064
+ zoomGestures: options.zoomGestures,
1065
+ wheelZoomFactor: options.wheelZoomFactor,
1066
+ sink
1067
+ }));
1068
+ return () => {
1069
+ for (const fn of cleanups) fn();
1070
+ };
1071
+ }
1072
+ //#endregion
1073
+ exports.attachSelectionHandle = attachSelectionHandle;
284
1074
  exports.bindPaintedImage = bindPaintedImage;
1075
+ exports.computeReleaseVelocity = computeReleaseVelocity;
285
1076
  exports.copySelection = copySelection;
1077
+ exports.createStageGestureController = createStageGestureController;
1078
+ exports.createStageSurface = createStageSurface;
286
1079
  exports.observeClientGeometry = observeClientGeometry;
287
1080
  exports.pickFile = pickFile;
288
1081
  exports.pickImageFile = pickImageFile;
@@ -291,6 +1084,9 @@ exports.projectAnchoredTarget = projectAnchoredTarget;
291
1084
  exports.sanitizeExternalUri = sanitizeExternalUri;
292
1085
  exports.saveFile = saveFile;
293
1086
  exports.svgCursor = svgCursor;
1087
+ exports.vibrationFeedback = vibrationFeedback;
1088
+ exports.wheelZoomFactor = wheelZoomFactor;
294
1089
  exports.wireSelectionClipboard = wireSelectionClipboard;
1090
+ exports.wkFeedback = wkFeedback;
295
1091
 
296
1092
  //# sourceMappingURL=index.cjs.map