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