@embedpdf/web 3.0.0-next.0 → 3.0.0-next.10

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,858 @@ function sanitizeExternalUri(uri, allowedSchemes = DEFAULT_ALLOWED_SCHEMES) {
105
235
  }
106
236
  }
107
237
  //#endregion
238
+ //#region src/painted-image.ts
239
+ /**
240
+ * Bind a raster source to an <img> without ever painting its incomplete-image
241
+ * placeholder. The source becomes visible on load; retained coverage is
242
+ * released only after the browser has had one full frame to present it.
243
+ *
244
+ * Call this from the framework's pre-paint mount hook and invoke the returned
245
+ * cleanup on unmount. Keeping this DOM lifecycle here lets every framework
246
+ * adapter remain a ref plus one bind/unbind hook.
247
+ */
248
+ function bindPaintedImage(image, source, callbacks) {
249
+ const controller = new AbortController();
250
+ let revoke;
251
+ let frame = 0;
252
+ let painted = false;
253
+ image.style.visibility = "hidden";
254
+ const onLoad = () => {
255
+ image.style.visibility = "visible";
256
+ frame = requestAnimationFrame(() => {
257
+ frame = requestAnimationFrame(() => {
258
+ frame = 0;
259
+ painted = true;
260
+ callbacks.onPainted();
261
+ });
262
+ });
263
+ };
264
+ image.addEventListener("load", onLoad, { once: true });
265
+ source.objectUrl(controller.signal).then((objectUrl) => {
266
+ if (controller.signal.aborted) {
267
+ objectUrl.revoke();
268
+ return;
269
+ }
270
+ revoke = objectUrl.revoke;
271
+ image.src = objectUrl.url;
272
+ }, () => {});
273
+ return () => {
274
+ controller.abort();
275
+ if (frame) cancelAnimationFrame(frame);
276
+ image.removeEventListener("load", onLoad);
277
+ image.style.visibility = "hidden";
278
+ image.removeAttribute("src");
279
+ revoke?.();
280
+ if (painted) callbacks.onUnpainted();
281
+ };
282
+ }
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;
1074
+ exports.bindPaintedImage = bindPaintedImage;
1075
+ exports.computeReleaseVelocity = computeReleaseVelocity;
1076
+ exports.copySelection = copySelection;
1077
+ exports.createStageGestureController = createStageGestureController;
1078
+ exports.createStageSurface = createStageSurface;
1079
+ exports.observeClientGeometry = observeClientGeometry;
108
1080
  exports.pickFile = pickFile;
109
1081
  exports.pickImageFile = pickImageFile;
1082
+ exports.positionAnchoredRect = positionAnchoredRect;
1083
+ exports.projectAnchoredTarget = projectAnchoredTarget;
110
1084
  exports.sanitizeExternalUri = sanitizeExternalUri;
111
1085
  exports.saveFile = saveFile;
112
1086
  exports.svgCursor = svgCursor;
1087
+ exports.vibrationFeedback = vibrationFeedback;
1088
+ exports.wheelZoomFactor = wheelZoomFactor;
1089
+ exports.wireSelectionClipboard = wireSelectionClipboard;
1090
+ exports.wkFeedback = wkFeedback;
113
1091
 
114
1092
  //# sourceMappingURL=index.cjs.map