@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/app/components/player/video-player.tsx +5 -3
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -1
- package/corpus/templates/design/.generated/bridge/embedded-wheel.generated.ts +3 -2
- package/corpus/templates/design/.generated/bridge/zoom.generated.ts +8 -2
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +46 -10
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +89 -63
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +3 -1
- package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +8 -2
- package/corpus/templates/design/app/components/design/bridge/zoom.bridge.ts +11 -2
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -4
- package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +19 -63
- package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +2 -1
- package/corpus/templates/design/shared/canvas-math.ts +11 -1
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +44 -18
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +44 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +77 -63
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +3 -1
- package/corpus/templates/slides/changelog/2026-08-26-thumbnail-arrow-keys-now-navigate-slides-after-selection.md +6 -0
- package/dist/client/embed-auth.js +4 -2
- package/dist/client/hooks/index.d.ts +1 -0
- package/dist/client/hooks/index.js +1 -0
- package/dist/client/use-pinch-zoom.js +17 -4
- package/dist/client/zoom-gesture.d.ts +74 -0
- package/dist/client/zoom-gesture.js +100 -0
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +13 -13
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/builder-browser.d.ts +13 -13
- package/dist/server/builder-browser.js +46 -15
- package/dist/server/builder-oauth.d.ts +12 -0
- package/dist/server/builder-oauth.js +13 -2
- package/dist/server/core-routes-plugin.js +54 -19
- package/dist/server/embed-session.js +1 -1
- package/dist/server/security-headers.d.ts +3 -0
- package/dist/server/security-headers.js +10 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/vite/client.js +2 -0
- package/docs/content/template-chat-developers.mdx +100 -13
- package/docs/content/template-chat-first-edits.mdx +5 -5
- package/docs/content/template-chat.mdx +84 -36
- 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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
708
|
-
*
|
|
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
|
|
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
|
|
719
|
-
|
|
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
|
-
|
|
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 }) =>
|
|
@@ -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.
|
|
295
|
-
// retained token
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
+
}
|