@agent-native/core 0.175.1 → 0.175.2-nightly-20260826165737

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.
Files changed (46) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/player/video-player.tsx +5 -3
  3. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
  4. package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
  5. package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
  6. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
  7. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
  8. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
  9. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
  10. package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
  11. package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
  12. package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
  13. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
  14. package/corpus/templates/design/shared/canvas-math.ts +11 -1
  15. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
  16. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +44 -1
  17. package/corpus/templates/slides/app/context/DeckContext.tsx +77 -63
  18. package/corpus/templates/slides/app/pages/DeckEditor.tsx +3 -1
  19. package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
  20. package/dist/client/embed-auth.js +4 -2
  21. package/dist/client/hooks/index.d.ts +1 -0
  22. package/dist/client/hooks/index.js +1 -0
  23. package/dist/client/use-pinch-zoom.js +17 -4
  24. package/dist/client/zoom-gesture.d.ts +74 -0
  25. package/dist/client/zoom-gesture.js +100 -0
  26. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  27. package/dist/notifications/routes.d.ts +3 -3
  28. package/dist/observability/routes.d.ts +5 -5
  29. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  30. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  31. package/dist/secrets/routes.d.ts +9 -9
  32. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  33. package/dist/server/builder-browser.d.ts +13 -13
  34. package/dist/server/builder-browser.js +46 -15
  35. package/dist/server/builder-oauth.d.ts +12 -0
  36. package/dist/server/builder-oauth.js +13 -2
  37. package/dist/server/core-routes-plugin.js +54 -19
  38. package/dist/server/embed-session.js +1 -1
  39. package/dist/server/security-headers.d.ts +3 -0
  40. package/dist/server/security-headers.js +10 -1
  41. package/dist/server/transcribe-voice.d.ts +1 -1
  42. package/dist/vite/client.js +2 -0
  43. package/docs/content/template-chat-developers.mdx +100 -13
  44. package/docs/content/template-chat-first-edits.mdx +5 -5
  45. package/docs/content/template-chat.mdx +84 -36
  46. package/package.json +3 -2
@@ -132,6 +132,22 @@ function isAgentPresenceUser(user: CollabUser): boolean {
132
132
  );
133
133
  }
134
134
 
135
+ function getNextSlideId(
136
+ slides: Slide[],
137
+ activeSlideId: string,
138
+ key: "ArrowUp" | "ArrowDown",
139
+ ): string | null {
140
+ const currentIndex = slides.findIndex((s) => s.id === activeSlideId);
141
+ if (currentIndex === -1) return null;
142
+
143
+ const nextIndex =
144
+ key === "ArrowUp"
145
+ ? Math.max(0, currentIndex - 1)
146
+ : Math.min(slides.length - 1, currentIndex + 1);
147
+
148
+ return nextIndex === currentIndex ? null : (slides[nextIndex]?.id ?? null);
149
+ }
150
+
135
151
  /** Small presence avatar circle with hover card showing name + email */
136
152
  function PresenceAvatarTip({
137
153
  user,
@@ -197,6 +213,7 @@ function SortableSlideThumb({
197
213
  index,
198
214
  isActive,
199
215
  onSelect,
216
+ onArrowNavigate,
200
217
  registerButtonRef,
201
218
  presenceUsers = [],
202
219
  aspectRatio,
@@ -219,6 +236,7 @@ function SortableSlideThumb({
219
236
  index: number;
220
237
  isActive: boolean;
221
238
  onSelect: () => void;
239
+ onArrowNavigate: (key: "ArrowUp" | "ArrowDown") => void;
222
240
  readOnly?: boolean;
223
241
  registerButtonRef: (slideId: string, node: HTMLButtonElement | null) => void;
224
242
  presenceUsers?: CollabUser[];
@@ -280,6 +298,14 @@ function SortableSlideThumb({
280
298
  type="button"
281
299
  {...(readOnly ? {} : attributes)}
282
300
  {...(readOnly ? {} : listeners)}
301
+ onKeyDown={(event) => {
302
+ listeners?.onKeyDown?.(event);
303
+ if (event.defaultPrevented) return;
304
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
305
+ event.preventDefault();
306
+ event.stopPropagation();
307
+ onArrowNavigate(event.key);
308
+ }}
283
309
  onClick={(event) => {
284
310
  // Safari does not focus a button on click, and the slide copy/paste
285
311
  // and delete shortcuts are scoped to a focused thumbnail.
@@ -603,6 +629,21 @@ export default function EditorSidebar({
603
629
  [describeSlideId],
604
630
  );
605
631
 
632
+ const navigateToSlide = useCallback(
633
+ (fromSlideId: string, key: "ArrowUp" | "ArrowDown") => {
634
+ const nextSlideId = getNextSlideId(slides, fromSlideId, key);
635
+ if (!nextSlideId) return;
636
+
637
+ onSelectSlide(nextSlideId);
638
+ requestAnimationFrame(() => {
639
+ const nextButton = slideButtonRefs.current.get(nextSlideId);
640
+ nextButton?.focus({ preventScroll: true });
641
+ nextButton?.scrollIntoView({ block: "nearest" });
642
+ });
643
+ },
644
+ [onSelectSlide, slides],
645
+ );
646
+
606
647
  const describeSlideIndex = describeSlideId
607
648
  ? slides.findIndex((s) => s.id === describeSlideId)
608
649
  : -1;
@@ -629,28 +670,12 @@ export default function EditorSidebar({
629
670
  return;
630
671
 
631
672
  e.preventDefault();
632
- const currentIndex = slides.findIndex((s) => s.id === activeSlideId);
633
- if (currentIndex === -1) return;
634
-
635
- const nextIndex =
636
- e.key === "ArrowUp"
637
- ? Math.max(0, currentIndex - 1)
638
- : Math.min(slides.length - 1, currentIndex + 1);
639
-
640
- if (nextIndex !== currentIndex) {
641
- const nextSlideId = slides[nextIndex].id;
642
- onSelectSlide(nextSlideId);
643
- requestAnimationFrame(() => {
644
- const nextButton = slideButtonRefs.current.get(nextSlideId);
645
- nextButton?.focus({ preventScroll: true });
646
- nextButton?.scrollIntoView({ block: "nearest" });
647
- });
648
- }
673
+ navigateToSlide(activeSlideId, e.key as "ArrowUp" | "ArrowDown");
649
674
  };
650
675
 
651
676
  document.addEventListener("keydown", handleKeyDown);
652
677
  return () => document.removeEventListener("keydown", handleKeyDown);
653
- }, [slides, activeSlideId, onSelectSlide]);
678
+ }, [activeSlideId, navigateToSlide]);
654
679
 
655
680
  return (
656
681
  <div className="flex h-full min-h-0 w-48 flex-shrink-0 flex-col bg-background sm:w-52">
@@ -677,6 +702,7 @@ export default function EditorSidebar({
677
702
  index={index}
678
703
  isActive={slide.id === activeSlideId}
679
704
  onSelect={() => onSelectSlide(slide.id)}
705
+ onArrowNavigate={(key) => navigateToSlide(slide.id, key)}
680
706
  readOnly={readOnly}
681
707
  registerButtonRef={registerSlideButton}
682
708
  presenceUsers={slidePresence?.get(slide.id) ?? []}
@@ -59,7 +59,11 @@ import {
59
59
  CanvasCommentPins,
60
60
  MultiSelectChip,
61
61
  } from "@/components/visual-editor";
62
- import type { Slide, UpdateSlideOptions } from "@/context/DeckContext";
62
+ import {
63
+ flushPendingSaves,
64
+ type Slide,
65
+ type UpdateSlideOptions,
66
+ } from "@/context/DeckContext";
63
67
  import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios";
64
68
  import {
65
69
  computeCanvasFitZoom,
@@ -1549,6 +1553,45 @@ export default function SlideEditor({
1549
1553
  );
1550
1554
  const previousSlideIdRef = useRef(slide.id);
1551
1555
 
1556
+ // Inline editing keeps its latest HTML in the DOM until blur so React does
1557
+ // not rerender the contentEditable on every keystroke. Hand that draft to
1558
+ // the keepalive queue before browser teardown.
1559
+ useEffect(() => {
1560
+ const flushInlineEditDraft = () => {
1561
+ const draft = inlineEditDraftRef.current;
1562
+ if (!draft) return;
1563
+ onUpdateSlideRef.current({ content: draft.content }, draft.slideId, {
1564
+ persistence: "debounced",
1565
+ });
1566
+ inlineEditDraftRef.current = null;
1567
+ flushPendingSaves();
1568
+ };
1569
+ const flushWhenHidden = () => {
1570
+ if (document.visibilityState === "hidden") flushInlineEditDraft();
1571
+ };
1572
+
1573
+ window.addEventListener("beforeunload", flushInlineEditDraft, {
1574
+ capture: true,
1575
+ });
1576
+ window.addEventListener("pagehide", flushInlineEditDraft, {
1577
+ capture: true,
1578
+ });
1579
+ document.addEventListener("visibilitychange", flushWhenHidden, {
1580
+ capture: true,
1581
+ });
1582
+ return () => {
1583
+ window.removeEventListener("beforeunload", flushInlineEditDraft, {
1584
+ capture: true,
1585
+ });
1586
+ window.removeEventListener("pagehide", flushInlineEditDraft, {
1587
+ capture: true,
1588
+ });
1589
+ document.removeEventListener("visibilitychange", flushWhenHidden, {
1590
+ capture: true,
1591
+ });
1592
+ };
1593
+ }, []);
1594
+
1552
1595
  const readCurrentSlideContentHtml = useCallback(() => {
1553
1596
  const slideContent = containerRef.current?.querySelector(
1554
1597
  ".slide-content",
@@ -382,7 +382,7 @@ const inFlightSaves = new Set<string>();
382
382
  const inFlightSaveChains = new Map<string, Promise<void>>();
383
383
  const inFlightSaveControllers = new Map<string, AbortController>();
384
384
  const deckSaveGenerations = new Map<string, number>();
385
- const immediateFlushRequests = new Set<string>();
385
+ const immediateFlushRequests = new Map<string, boolean>();
386
386
  const deckSaveRetryAttempts = new Map<string, number>();
387
387
  const failedSaveDecks = new Set<string>();
388
388
  const saveStateListeners = new Set<() => void>();
@@ -502,11 +502,64 @@ function deckPayload(deck: Deck): Record<string, unknown> {
502
502
  * the `save-deck` action is called first, then any trailing granular ops are
503
503
  * sent through `patch-deck`.
504
504
  */
505
+ async function sendKeepaliveAction(
506
+ url: string,
507
+ method: "POST" | "PUT",
508
+ body: Record<string, unknown>,
509
+ signal?: AbortSignal,
510
+ ): Promise<void> {
511
+ const response = await fetch(url, {
512
+ method,
513
+ headers: {
514
+ "Content-Type": "application/json",
515
+ "X-Agent-Native-Frontend": "1",
516
+ },
517
+ body: JSON.stringify(body),
518
+ cache: "no-store",
519
+ keepalive: true,
520
+ signal,
521
+ });
522
+ if (!response.ok) {
523
+ throw new Error(`Action request failed with status ${response.status}`);
524
+ }
525
+ }
526
+
505
527
  async function persistDeckOps(
506
528
  deckId: string,
507
529
  ops: GranularOp[],
508
530
  signal?: AbortSignal,
531
+ options?: { keepalive?: boolean },
509
532
  ) {
533
+ if (options?.keepalive) {
534
+ const actionsBase = agentNativePath("/_agent-native/actions");
535
+ if (ops[0].op === "full-replace") {
536
+ const deck = ops[0].deck;
537
+ await sendKeepaliveAction(
538
+ `${actionsBase}/save-deck`,
539
+ "PUT",
540
+ { deckId, deck: deckPayload(deck) },
541
+ signal,
542
+ );
543
+ const trailingOps = ops.slice(1) as PatchDeckOp[];
544
+ if (trailingOps.length > 0) {
545
+ await sendKeepaliveAction(
546
+ `${actionsBase}/patch-deck`,
547
+ "POST",
548
+ { deckId, operations: trailingOps },
549
+ signal,
550
+ );
551
+ }
552
+ } else {
553
+ await sendKeepaliveAction(
554
+ `${actionsBase}/patch-deck`,
555
+ "POST",
556
+ { deckId, operations: ops as PatchDeckOp[] },
557
+ signal,
558
+ );
559
+ }
560
+ return;
561
+ }
562
+
510
563
  if (ops[0].op === "full-replace") {
511
564
  // Legacy full-deck write — used by undo/redo and setDeckSlides.
512
565
  // `callAction` bounds it so a stalled save can't wedge `inFlightSaves`
@@ -547,14 +600,21 @@ async function persistDeckOps(
547
600
  * could otherwise complete out of order and let a stale drag overwrite a
548
601
  * newer resize.
549
602
  */
550
- function drainPendingDeckOps(deckId: string): Promise<void> {
603
+ function drainPendingDeckOps(
604
+ deckId: string,
605
+ options?: { keepalive?: boolean },
606
+ ): Promise<void> {
551
607
  const timer = pendingSaves.get(deckId);
552
608
  if (timer) clearTimeout(timer);
553
609
  pendingSaves.delete(deckId);
554
610
 
555
611
  const active = inFlightSaveChains.get(deckId);
556
612
  if (active) {
557
- immediateFlushRequests.add(deckId);
613
+ immediateFlushRequests.set(
614
+ deckId,
615
+ (immediateFlushRequests.get(deckId) ?? false) ||
616
+ options?.keepalive === true,
617
+ );
558
618
  notifySaveListeners();
559
619
  return active;
560
620
  }
@@ -574,7 +634,7 @@ function drainPendingDeckOps(deckId: string): Promise<void> {
574
634
  inFlightOpSlides.set(deckId, ops);
575
635
  const isCurrentGeneration = () =>
576
636
  (deckSaveGenerations.get(deckId) ?? 0) === generation;
577
- const next = persistDeckOps(deckId, ops, controller?.signal)
637
+ const next = persistDeckOps(deckId, ops, controller?.signal, options)
578
638
  .then(() => {
579
639
  if (!isCurrentGeneration()) return;
580
640
  deckSaveRetryAttempts.delete(deckId);
@@ -613,10 +673,15 @@ function drainPendingDeckOps(deckId: string): Promise<void> {
613
673
  }
614
674
  inFlightSaves.delete(deckId);
615
675
  inFlightOpSlides.delete(deckId);
616
- const flushImmediately = immediateFlushRequests.delete(deckId);
676
+ const requestedFlush = immediateFlushRequests.get(deckId);
677
+ const flushImmediately = requestedFlush !== undefined;
678
+ immediateFlushRequests.delete(deckId);
617
679
  notifySaveListeners();
618
680
  if (flushImmediately) {
619
- void drainPendingDeckOps(deckId);
681
+ void drainPendingDeckOps(
682
+ deckId,
683
+ requestedFlush ? { keepalive: true } : undefined,
684
+ );
620
685
  }
621
686
  }
622
687
  });
@@ -704,71 +769,20 @@ function saveDeckToAPI(deck: Deck) {
704
769
  }
705
770
 
706
771
  /**
707
- * Synchronously flush every pending (debounced) deck op queue using
708
- * `fetch(..., { keepalive: true })` so in-flight edits survive a tab
772
+ * Flush every pending (debounced) deck op through the same per-deck queue used
773
+ * by normal saves, using keepalive transport so in-flight edits survive a tab
709
774
  * close / navigation. Called from a `pagehide` / `visibilitychange(hidden)`
710
- * handler — without it there is a ~500ms window (the debounce) where the
775
+ * handler - without it there is a ~500ms window (the debounce) where the
711
776
  * user's most recent edits are only in memory and are lost on tab close.
712
777
  *
713
778
  * keepalive requests are best-effort and capped (~64KB by the browser), which
714
779
  * is fine: granular ops are small, and if a full-replace payload is too large
715
780
  * to send keepalive the normal debounce/poll path still catches up on reopen.
716
781
  */
717
- function flushPendingSaves() {
718
- const actionsBase = agentNativePath("/_agent-native/actions");
719
- const actionUrl = `${actionsBase}/patch-deck`;
720
- const saveDeckUrl = `${actionsBase}/save-deck`;
721
- for (const [deckId, timer] of pendingSaves) {
722
- clearTimeout(timer);
723
- const ops = pendingOpsQueue.get(deckId);
724
- pendingOpsQueue.delete(deckId);
725
- if (!ops || ops.length === 0) continue;
726
- try {
727
- if (ops[0].op === "full-replace") {
728
- const deck = ops[0].deck;
729
- void fetch(saveDeckUrl, {
730
- method: "PUT",
731
- headers: {
732
- "Content-Type": "application/json",
733
- "X-Agent-Native-Frontend": "1",
734
- },
735
- body: JSON.stringify({ deckId, deck }),
736
- keepalive: true,
737
- });
738
- const trailingOps = ops.slice(1) as PatchDeckOp[];
739
- if (trailingOps.length === 0) continue;
740
- void fetch(actionUrl, {
741
- method: "POST",
742
- headers: {
743
- "Content-Type": "application/json",
744
- "X-Agent-Native-Frontend": "1",
745
- },
746
- body: JSON.stringify({
747
- deckId,
748
- operations: trailingOps,
749
- }),
750
- keepalive: true,
751
- });
752
- } else {
753
- void fetch(actionUrl, {
754
- method: "POST",
755
- headers: {
756
- "Content-Type": "application/json",
757
- "X-Agent-Native-Frontend": "1",
758
- },
759
- body: JSON.stringify({
760
- deckId,
761
- operations: ops as PatchDeckOp[],
762
- }),
763
- keepalive: true,
764
- });
765
- }
766
- } catch {
767
- // Best-effort — nothing more we can do as the page is unloading.
768
- }
782
+ export function flushPendingSaves() {
783
+ for (const deckId of [...pendingSaves.keys()]) {
784
+ void drainPendingDeckOps(deckId, { keepalive: true });
769
785
  }
770
- pendingSaves.clear();
771
- notifySaveListeners();
772
786
  }
773
787
 
774
788
  function discardPendingDeckOps(deckId: string) {
@@ -182,7 +182,9 @@ export default function DeckEditor() {
182
182
  const { hasUnsavedChanges: hasUnsavedSave } = useSaveState();
183
183
  const hasPendingDeckEdits =
184
184
  inlineEditActive || (id ? hasUnsavedDeckChanges(id) : hasUnsavedSave);
185
- usePendingDeckUnloadGuard(hasPendingDeckEdits);
185
+ // Inline drafts flush through SlideEditor keepalive handlers. The native
186
+ // prompt only needs to cover queued or in-flight writes now.
187
+ usePendingDeckUnloadGuard(id ? hasUnsavedDeckChanges(id) : hasUnsavedSave);
186
188
  const pendingDeckNavigationBlocker = useBlocker(
187
189
  useCallback(
188
190
  ({ currentLocation, nextLocation }) =>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-26
4
+ ---
5
+
6
+ Clicking a slide thumbnail now keeps arrow keys on slide navigation instead of scrolling the thumbnail pane.
@@ -291,8 +291,10 @@ function isOpaqueOriginFrame(win) {
291
291
  function stripTokenFromUrl(win) {
292
292
  // Keep the token in the URL for opaque-origin frames — see
293
293
  // isOpaqueOriginFrame. Stripping it there breaks re-auth on any document
294
- // reload. Referrer-Policy is set to no-referrer on embed responses, so the
295
- // retained token does not leak via the Referer header.
294
+ // reload. Embed responses now use Referrer-Policy: same-origin, but that
295
+ // never leaks the retained token here: an opaque origin never equals any
296
+ // other origin (including its own), so "same-origin" requests from this
297
+ // document never qualify and no Referer is sent at all.
296
298
  if (isOpaqueOriginFrame(win))
297
299
  return;
298
300
  try {
@@ -10,5 +10,6 @@ export { createAgentNativeQueryClient } from "../create-query-client.js";
10
10
  export { AppProviders, type AppProvidersProps } from "../app-providers.js";
11
11
  export { APP_CHAT_SIDEBAR_STATE_EVENT, APP_CHAT_SIDEBAR_STATE_MESSAGE, APP_CHAT_SIDEBAR_STATE_REQUEST_MESSAGE, buildAppChatSidebarStateMessage, buildAppChatSidebarStateRequest, isPerAppChatStorageKey, requestPerAppChatCommand, usePerAppChatState, usePerAppChatOpen, type AppChatSidebarCommand, type AppChatSidebarState, } from "../app-chat-sidebar.js";
12
12
  export { usePinchZoom, type UsePinchZoomOptions } from "../use-pinch-zoom.js";
13
+ export { isPinchZoomDelta, normalizeWheelDeltaPx, resolveZoomGestureDevice, MAX_PINCH_DELTA_PX, ZOOM_GESTURE_IDLE_RESET_MS, type ZoomGestureDevice, } from "../zoom-gesture.js";
13
14
  export { useAvatarUrl, uploadAvatar, invalidateAvatarCache, } from "../use-avatar.js";
14
15
  export { usePollLoop, type UsePollLoopOptions, type UsePollLoopHandle, } from "../use-poll-loop.js";
@@ -10,5 +10,6 @@ export { createAgentNativeQueryClient } from "../create-query-client.js";
10
10
  export { AppProviders } from "../app-providers.js";
11
11
  export { APP_CHAT_SIDEBAR_STATE_EVENT, APP_CHAT_SIDEBAR_STATE_MESSAGE, APP_CHAT_SIDEBAR_STATE_REQUEST_MESSAGE, buildAppChatSidebarStateMessage, buildAppChatSidebarStateRequest, isPerAppChatStorageKey, requestPerAppChatCommand, usePerAppChatState, usePerAppChatOpen, } from "../app-chat-sidebar.js";
12
12
  export { usePinchZoom } from "../use-pinch-zoom.js";
13
+ export { isPinchZoomDelta, normalizeWheelDeltaPx, resolveZoomGestureDevice, MAX_PINCH_DELTA_PX, ZOOM_GESTURE_IDLE_RESET_MS, } from "../zoom-gesture.js";
13
14
  export { useAvatarUrl, uploadAvatar, invalidateAvatarCache, } from "../use-avatar.js";
14
15
  export { usePollLoop, } from "../use-poll-loop.js";
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef } from "react";
2
+ import { clampZoomFactor, normalizeWheelDeltaPx, resolveZoomGestureDevice, zoomFactorForWheelDelta, } from "./zoom-gesture.js";
2
3
  /**
3
4
  * Pinch-to-zoom for canvas-style editors. Wires the trackpad pinch / Cmd+scroll
4
5
  * wheel gesture and 2-pointer touchscreen pinch onto a scrolling container.
@@ -46,6 +47,7 @@ export function usePinchZoom({ containerRef, zoom, setZoom, min = 25, max = 400,
46
47
  let simScrollLeft = 0;
47
48
  let simScrollTop = 0;
48
49
  let rafId = null;
50
+ let gestureDevice = null;
49
51
  const flush = () => {
50
52
  rafId = null;
51
53
  if (pendingZoom === null)
@@ -68,13 +70,23 @@ export function usePinchZoom({ containerRef, zoom, setZoom, min = 25, max = 400,
68
70
  const handleWheel = (e) => {
69
71
  if (!(e.ctrlKey || e.metaKey))
70
72
  return;
71
- e.preventDefault();
73
+ // Chrome sends non-cancelable wheel events during a fling; cancelling one
74
+ // is a no-op that logs an Intervention per event and still scrolls.
75
+ if (e.cancelable)
76
+ e.preventDefault();
77
+ gestureDevice = resolveZoomGestureDevice({
78
+ deltaY: e.deltaY,
79
+ deltaMode: e.deltaMode,
80
+ ctrlKey: e.ctrlKey,
81
+ metaKey: e.metaKey,
82
+ atMs: e.timeStamp,
83
+ previous: gestureDevice,
84
+ });
72
85
  // Use the latest not-yet-applied zoom (if a flush is pending) so rapid
73
86
  // wheel events within the same frame compound correctly instead of
74
87
  // each computing off the last-committed React state.
75
88
  const currentZoom = pendingZoom ?? zoomRef.current;
76
- const clampedDelta = Math.max(-50, Math.min(50, e.deltaY));
77
- const factor = Math.exp(-clampedDelta * 0.01);
89
+ const factor = clampZoomFactor(zoomFactorForWheelDelta(normalizeWheelDeltaPx(e.deltaY, e.deltaMode), gestureDevice.pinch));
78
90
  const nextZoom = clamp(currentZoom * factor);
79
91
  if (nextZoom === currentZoom)
80
92
  return;
@@ -137,7 +149,8 @@ export function usePinchZoom({ containerRef, zoom, setZoom, min = 25, max = 400,
137
149
  pendingZoom = nextZoom;
138
150
  scheduleFlush();
139
151
  }
140
- e.preventDefault();
152
+ if (e.cancelable)
153
+ e.preventDefault();
141
154
  }
142
155
  };
143
156
  const handlePointerEnd = (e) => {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Wheel/pinch → zoom-factor conversion, shared by every zoomable canvas.
3
+ *
4
+ * A mouse wheel and a trackpad pinch arrive as the same event type, their
5
+ * deltas overlap, and the sensitivity each needs differs by roughly 8x — so a
6
+ * surface that re-implements either half gets the split wrong the same way.
7
+ */
8
+ /**
9
+ * Largest |deltaY| finger separation produces; above it every device is a
10
+ * wheel. macOS reports an accelerated mouse wheel as large FRACTIONAL deltas,
11
+ * so fractionality alone cannot separate the two.
12
+ */
13
+ export declare const MAX_PINCH_DELTA_PX = 40;
14
+ /**
15
+ * A ctrl/meta wheel run with a longer gap than this starts a new gesture and
16
+ * the device is classified again.
17
+ */
18
+ export declare const ZOOM_GESTURE_IDLE_RESET_MS = 150;
19
+ /** Which curve a zoom gesture's deltas run through, latched for its duration. */
20
+ export type ZoomGestureDevice = {
21
+ pinch: boolean;
22
+ lastEventAtMs: number;
23
+ };
24
+ /**
25
+ * Whether a delta is small enough to be finger separation. Magnitude only —
26
+ * `resolveZoomGestureDevice` owns the modifier and delta-mode part of the
27
+ * decision, and is what callers should ask.
28
+ */
29
+ export declare function isPinchZoomDelta(deltaY: number): boolean;
30
+ /**
31
+ * Latch the input device for a zoom gesture: one delta cannot identify a device
32
+ * (macOS ramps an accelerated wheel up from pinch-sized values), so this only
33
+ * ever loosens pinch → wheel, and re-decides after an idle gap.
34
+ */
35
+ export declare function resolveZoomGestureDevice(args: {
36
+ deltaY: number;
37
+ deltaMode: number;
38
+ ctrlKey: boolean;
39
+ metaKey: boolean;
40
+ atMs: number;
41
+ previous: ZoomGestureDevice | null;
42
+ }): ZoomGestureDevice;
43
+ /** One mouse-wheel detent in `deltaMode === 0` pixels (Chrome/Safari). */
44
+ export declare const MOUSE_WHEEL_NOTCH_PX = 100;
45
+ /** Zoom multiplier for one full mouse-wheel notch, matching Figma's feel. */
46
+ export declare const ZOOM_STEP_PER_NOTCH = 1.1;
47
+ /** Multiplier exponent per pixel of trackpad pinch delta. */
48
+ export declare const PINCH_ZOOM_SENSITIVITY = 0.0075;
49
+ /** Ceiling on a single frame's zoom change, in either direction. */
50
+ export declare const MAX_ZOOM_FACTOR_PER_FRAME = 1.6;
51
+ /** Pixels one `DOM_DELTA_LINE` step stands for. */
52
+ export declare const WHEEL_LINE_HEIGHT_PX = 16;
53
+ /** Pixels one `DOM_DELTA_PAGE` step stands for. */
54
+ export declare const WHEEL_PAGE_HEIGHT_PX = 800;
55
+ /**
56
+ * Convert a delta to pixels. The curves below are calibrated in pixels, so a
57
+ * Firefox line-mode tick of 3 is a full notch, not 3px of travel. Classify
58
+ * with the RAW delta and its real mode — normalising first would push a line
59
+ * tick into the pinch band.
60
+ */
61
+ export declare function normalizeWheelDeltaPx(delta: number, deltaMode: number): number;
62
+ /** Unclamped zoom multiplier for a wheel/pinch delta. Negative delta (scroll
63
+ * up / pinch open) zooms in, matching the browser's wheel convention. */
64
+ export declare function zoomFactorForWheelDelta(deltaY: number, pinch: boolean): number;
65
+ /** Bounds one frame's zoom change symmetrically in multiplicative space, so
66
+ * zooming in and back out by the same clamped amount is a round trip. */
67
+ export declare function clampZoomFactor(factor: number): number;
68
+ /**
69
+ * Fold one event into a frame's accumulated zoom. Both curves are exponential
70
+ * in the delta, so multiplying per-event factors equals summing same-curve
71
+ * deltas — and unlike a delta sum it stays exact when a frame catches two
72
+ * curves, instead of forcing the accumulator to discard one of them.
73
+ */
74
+ export declare function accumulateZoomFactor(pendingFactor: number, deltaY: number, pinch: boolean): number;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Wheel/pinch → zoom-factor conversion, shared by every zoomable canvas.
3
+ *
4
+ * A mouse wheel and a trackpad pinch arrive as the same event type, their
5
+ * deltas overlap, and the sensitivity each needs differs by roughly 8x — so a
6
+ * surface that re-implements either half gets the split wrong the same way.
7
+ */
8
+ /**
9
+ * Largest |deltaY| finger separation produces; above it every device is a
10
+ * wheel. macOS reports an accelerated mouse wheel as large FRACTIONAL deltas,
11
+ * so fractionality alone cannot separate the two.
12
+ */
13
+ export const MAX_PINCH_DELTA_PX = 40;
14
+ /**
15
+ * A ctrl/meta wheel run with a longer gap than this starts a new gesture and
16
+ * the device is classified again.
17
+ */
18
+ export const ZOOM_GESTURE_IDLE_RESET_MS = 150;
19
+ /**
20
+ * Whether a delta is small enough to be finger separation. Magnitude only —
21
+ * `resolveZoomGestureDevice` owns the modifier and delta-mode part of the
22
+ * decision, and is what callers should ask.
23
+ */
24
+ export function isPinchZoomDelta(deltaY) {
25
+ if (!Number.isFinite(deltaY))
26
+ return false;
27
+ return Math.abs(deltaY) < MAX_PINCH_DELTA_PX;
28
+ }
29
+ /**
30
+ * Latch the input device for a zoom gesture: one delta cannot identify a device
31
+ * (macOS ramps an accelerated wheel up from pinch-sized values), so this only
32
+ * ever loosens pinch → wheel, and re-decides after an idle gap.
33
+ */
34
+ export function resolveZoomGestureDevice(args) {
35
+ const { deltaY, deltaMode, ctrlKey, metaKey, atMs, previous } = args;
36
+ const at = Number.isFinite(atMs) ? atMs : 0;
37
+ // Line/page modes are always discrete wheels. A held Cmd means the user
38
+ // reached for a modifier, so the wheel is whatever device they are holding —
39
+ // only the browser's synthetic ctrl+wheel can be a trackpad pinch.
40
+ const looksLikePinch = deltaMode === 0 && ctrlKey && !metaKey && isPinchZoomDelta(deltaY);
41
+ const continuesGesture = previous !== null &&
42
+ Number.isFinite(previous.lastEventAtMs) &&
43
+ at - previous.lastEventAtMs <= ZOOM_GESTURE_IDLE_RESET_MS;
44
+ if (!continuesGesture)
45
+ return { pinch: looksLikePinch, lastEventAtMs: at };
46
+ return { pinch: previous.pinch && looksLikePinch, lastEventAtMs: at };
47
+ }
48
+ /** One mouse-wheel detent in `deltaMode === 0` pixels (Chrome/Safari). */
49
+ export const MOUSE_WHEEL_NOTCH_PX = 100;
50
+ /** Zoom multiplier for one full mouse-wheel notch, matching Figma's feel. */
51
+ export const ZOOM_STEP_PER_NOTCH = 1.1;
52
+ /** Multiplier exponent per pixel of trackpad pinch delta. */
53
+ export const PINCH_ZOOM_SENSITIVITY = 0.0075;
54
+ /** Ceiling on a single frame's zoom change, in either direction. */
55
+ export const MAX_ZOOM_FACTOR_PER_FRAME = 1.6;
56
+ /** Pixels one `DOM_DELTA_LINE` step stands for. */
57
+ export const WHEEL_LINE_HEIGHT_PX = 16;
58
+ /** Pixels one `DOM_DELTA_PAGE` step stands for. */
59
+ export const WHEEL_PAGE_HEIGHT_PX = 800;
60
+ /**
61
+ * Convert a delta to pixels. The curves below are calibrated in pixels, so a
62
+ * Firefox line-mode tick of 3 is a full notch, not 3px of travel. Classify
63
+ * with the RAW delta and its real mode — normalising first would push a line
64
+ * tick into the pinch band.
65
+ */
66
+ export function normalizeWheelDeltaPx(delta, deltaMode) {
67
+ if (!Number.isFinite(delta))
68
+ return 0;
69
+ if (deltaMode === 1)
70
+ return delta * WHEEL_LINE_HEIGHT_PX;
71
+ if (deltaMode === 2)
72
+ return delta * WHEEL_PAGE_HEIGHT_PX;
73
+ return delta;
74
+ }
75
+ /** Unclamped zoom multiplier for a wheel/pinch delta. Negative delta (scroll
76
+ * up / pinch open) zooms in, matching the browser's wheel convention. */
77
+ export function zoomFactorForWheelDelta(deltaY, pinch) {
78
+ if (!Number.isFinite(deltaY) || deltaY === 0)
79
+ return 1;
80
+ return pinch
81
+ ? Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY)
82
+ : Math.pow(ZOOM_STEP_PER_NOTCH, -deltaY / MOUSE_WHEEL_NOTCH_PX);
83
+ }
84
+ /** Bounds one frame's zoom change symmetrically in multiplicative space, so
85
+ * zooming in and back out by the same clamped amount is a round trip. */
86
+ export function clampZoomFactor(factor) {
87
+ if (!Number.isFinite(factor) || factor <= 0)
88
+ return 1;
89
+ return Math.min(MAX_ZOOM_FACTOR_PER_FRAME, Math.max(1 / MAX_ZOOM_FACTOR_PER_FRAME, factor));
90
+ }
91
+ /**
92
+ * Fold one event into a frame's accumulated zoom. Both curves are exponential
93
+ * in the delta, so multiplying per-event factors equals summing same-curve
94
+ * deltas — and unlike a delta sum it stays exact when a frame catches two
95
+ * curves, instead of forcing the accumulator to discard one of them.
96
+ */
97
+ export function accumulateZoomFactor(pendingFactor, deltaY, pinch) {
98
+ const base = Number.isFinite(pendingFactor) && pendingFactor > 0 ? pendingFactor : 1;
99
+ return base * zoomFactorForWheelDelta(deltaY, pinch);
100
+ }