@agent-native/core 0.131.3 → 0.131.4
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/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AgentPanel.tsx +58 -34
- package/corpus/core/src/localization/default-messages.ts +6 -0
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +3 -1
- package/corpus/templates/design/actions/get-design.ts +11 -3
- package/corpus/templates/design/actions/present-design-variants.ts +62 -5
- package/corpus/templates/design/app/components/design/BreakpointBar.tsx +64 -1
- package/corpus/templates/design/app/components/design/EditPanel.tsx +199 -25
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +160 -46
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +7 -1
- package/corpus/templates/design/app/components/design/edit-panel/effects-properties.tsx +59 -12
- package/corpus/templates/design/app/components/design/edit-panel/fill-properties.tsx +1 -1
- package/corpus/templates/design/app/components/design/edit-panel/inspector-controls.tsx +34 -29
- package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +2 -2
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +28 -10
- package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +15 -28
- package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +4 -3
- package/corpus/templates/design/app/components/design/multi-screen/chrome-transitions.ts +12 -8
- package/corpus/templates/design/app/components/design/multi-screen/culling.ts +34 -9
- package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +13 -27
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +8 -1
- package/corpus/templates/design/app/components/design/multi-screen/zoom-gesture.ts +95 -0
- package/corpus/templates/design/app/i18n-data.ts +121 -10
- package/corpus/templates/design/app/pages/design-editor/data-operations.ts +45 -0
- package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +59 -0
- package/corpus/templates/design/app/pages/design-editor/text-edit-utils.ts +115 -40
- package/corpus/templates/design/changelog/2026-07-28-generated-design-variants-no-longer-overlap-each-other-on-th.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-auto-layout-direction-icons-show-an-arrow.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-breakpoints-no-longer-overlap-neighbouring-screens.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-canvas-is-more-responsive-when-selecting-and-adding-frames.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-canvas-zoom-is-smoother-and-labels-hold-their-size.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-double-click-a-frame-now-drills-into-its-layers.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-export-preview-shows-the-real-render.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-figma-parity-shortcuts.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-more-breakpoint-width-presets.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-resize-a-selected-frame-to-a-device-preset.md +6 -0
- package/corpus/templates/design/changelog/2026-07-29-text-tool-reaches-the-right-surface.md +6 -0
- package/corpus/templates/design/shared/canvas-frames.ts +23 -0
- package/corpus/templates/design/shared/code-layer.ts +54 -2
- package/corpus/templates/design/shared/responsive-frame-layout.ts +55 -0
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +16 -5
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +6 -0
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +6 -0
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/AgentPanel.tsx +58 -34
- package/src/localization/default-messages.ts +6 -0
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
SelectTrigger,
|
|
12
12
|
SelectValue,
|
|
13
13
|
} from "@/components/ui/select";
|
|
14
|
+
import {
|
|
15
|
+
Tooltip,
|
|
16
|
+
TooltipContent,
|
|
17
|
+
TooltipTrigger,
|
|
18
|
+
} from "@/components/ui/tooltip";
|
|
14
19
|
import { cn } from "@/lib/utils";
|
|
15
20
|
|
|
16
21
|
export type ExportFormat = "png" | "jpg" | "svg" | "pdf" | "webp";
|
|
@@ -399,16 +404,29 @@ function ExportRow({
|
|
|
399
404
|
aria-label={labels.suffix}
|
|
400
405
|
/>
|
|
401
406
|
|
|
402
|
-
{/* Remove row
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
407
|
+
{/* Remove row — only rendered once there is more than one export to
|
|
408
|
+
remove. A permanently disabled × cannot explain itself: `disabled`
|
|
409
|
+
suppresses pointer events, so it carries no tooltip either, and the
|
|
410
|
+
single-row default left an unexplained dead icon in the row. The span
|
|
411
|
+
keeps hover alive while a run is in flight and the button is disabled. */}
|
|
412
|
+
{canRemove ? (
|
|
413
|
+
<Tooltip>
|
|
414
|
+
<TooltipTrigger asChild>
|
|
415
|
+
<span className="flex shrink-0">
|
|
416
|
+
<button
|
|
417
|
+
type="button"
|
|
418
|
+
aria-label={labels.removeExport}
|
|
419
|
+
disabled={isDisabled}
|
|
420
|
+
className="flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
421
|
+
onClick={() => onRemoveRow(row.id)}
|
|
422
|
+
>
|
|
423
|
+
<IconX className="size-3" />
|
|
424
|
+
</button>
|
|
425
|
+
</span>
|
|
426
|
+
</TooltipTrigger>
|
|
427
|
+
<TooltipContent side="left">{labels.removeExport}</TooltipContent>
|
|
428
|
+
</Tooltip>
|
|
429
|
+
) : null}
|
|
412
430
|
</div>
|
|
413
431
|
);
|
|
414
432
|
}
|
|
@@ -109,7 +109,8 @@ export function IconPaddingVertical({ className }: IconProps) {
|
|
|
109
109
|
);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
// Flow horizontal:
|
|
112
|
+
// Flow horizontal: item + arrow. Keep the arrow — two symmetric boxes read as
|
|
113
|
+
// "this axis" but not "this direction", forcing a tooltip hover to disambiguate.
|
|
113
114
|
export function IconFlowHorizontal({ className }: IconProps) {
|
|
114
115
|
return (
|
|
115
116
|
<svg
|
|
@@ -124,31 +125,24 @@ export function IconFlowHorizontal({ className }: IconProps) {
|
|
|
124
125
|
>
|
|
125
126
|
{/* Outer container — wide landscape frame */}
|
|
126
127
|
<rect x="2" y="6" width="20" height="12" rx="2" strokeWidth={1.5} />
|
|
127
|
-
{/*
|
|
128
|
+
{/* First item */}
|
|
128
129
|
<rect
|
|
129
|
-
x="5"
|
|
130
|
-
y="9"
|
|
131
|
-
width="5"
|
|
132
|
-
height="6"
|
|
133
|
-
rx="1"
|
|
134
|
-
fill="currentColor"
|
|
135
|
-
stroke="none"
|
|
136
|
-
/>
|
|
137
|
-
{/* Right item */}
|
|
138
|
-
<rect
|
|
139
|
-
x="14"
|
|
130
|
+
x="4.5"
|
|
140
131
|
y="9"
|
|
141
|
-
width="
|
|
132
|
+
width="4"
|
|
142
133
|
height="6"
|
|
143
134
|
rx="1"
|
|
144
135
|
fill="currentColor"
|
|
145
136
|
stroke="none"
|
|
146
137
|
/>
|
|
138
|
+
{/* Direction arrow — left to right */}
|
|
139
|
+
<path d="M11 12H18.5" />
|
|
140
|
+
<path d="M16.3 9.8 18.5 12 16.3 14.2" />
|
|
147
141
|
</svg>
|
|
148
142
|
);
|
|
149
143
|
}
|
|
150
144
|
|
|
151
|
-
// Flow vertical:
|
|
145
|
+
// Flow vertical: an item plus a downward arrow — see IconFlowHorizontal.
|
|
152
146
|
export function IconFlowVertical({ className }: IconProps) {
|
|
153
147
|
return (
|
|
154
148
|
<svg
|
|
@@ -163,26 +157,19 @@ export function IconFlowVertical({ className }: IconProps) {
|
|
|
163
157
|
>
|
|
164
158
|
{/* Outer container — tall portrait frame */}
|
|
165
159
|
<rect x="6" y="2" width="12" height="20" rx="2" strokeWidth={1.5} />
|
|
166
|
-
{/*
|
|
160
|
+
{/* First item */}
|
|
167
161
|
<rect
|
|
168
162
|
x="9"
|
|
169
|
-
y="5"
|
|
163
|
+
y="4.5"
|
|
170
164
|
width="6"
|
|
171
|
-
height="
|
|
172
|
-
rx="1"
|
|
173
|
-
fill="currentColor"
|
|
174
|
-
stroke="none"
|
|
175
|
-
/>
|
|
176
|
-
{/* Bottom item */}
|
|
177
|
-
<rect
|
|
178
|
-
x="9"
|
|
179
|
-
y="14"
|
|
180
|
-
width="6"
|
|
181
|
-
height="5"
|
|
165
|
+
height="4"
|
|
182
166
|
rx="1"
|
|
183
167
|
fill="currentColor"
|
|
184
168
|
stroke="none"
|
|
185
169
|
/>
|
|
170
|
+
{/* Direction arrow — top to bottom */}
|
|
171
|
+
<path d="M12 11V18.5" />
|
|
172
|
+
<path d="M9.8 16.3 12 18.5 14.2 16.3" />
|
|
186
173
|
</svg>
|
|
187
174
|
);
|
|
188
175
|
}
|
|
@@ -78,7 +78,8 @@ export const DESIGN_SHORTCUTS: readonly DesignShortcutDefinition[] = [
|
|
|
78
78
|
shortcut({
|
|
79
79
|
id: "frame-tool",
|
|
80
80
|
category: "tools",
|
|
81
|
-
|
|
81
|
+
// Figma binds both to the frame tool; A is the one long-time users reach for.
|
|
82
|
+
bindings: ["f", "a"],
|
|
82
83
|
labelKey: "designEditor.keyboardShortcuts.commands.frameTool",
|
|
83
84
|
handler: "onFrameTool",
|
|
84
85
|
}),
|
|
@@ -171,7 +172,7 @@ export const DESIGN_SHORTCUTS: readonly DesignShortcutDefinition[] = [
|
|
|
171
172
|
shortcut({
|
|
172
173
|
id: "zoom-reset",
|
|
173
174
|
category: "zoom",
|
|
174
|
-
bindings: ["$mod+0"],
|
|
175
|
+
bindings: ["shift+0", "$mod+0"],
|
|
175
176
|
labelKey: "designEditor.keyboardShortcuts.commands.zoomReset",
|
|
176
177
|
handler: "onZoomReset",
|
|
177
178
|
}),
|
|
@@ -502,7 +503,7 @@ export const DESIGN_SHORTCUTS: readonly DesignShortcutDefinition[] = [
|
|
|
502
503
|
shortcut({
|
|
503
504
|
id: "ungroup",
|
|
504
505
|
category: "layout",
|
|
505
|
-
bindings: ["$mod+backspace"],
|
|
506
|
+
bindings: ["$mod+shift+g", "$mod+backspace"],
|
|
506
507
|
labelKey: "designEditor.keyboardShortcuts.commands.ungroup",
|
|
507
508
|
handler: "onUngroup",
|
|
508
509
|
}),
|
|
@@ -7,10 +7,6 @@ const CHROME_OPACITY_TRANSITION = "opacity 150ms ease-out";
|
|
|
7
7
|
const CHROME_BORDER_SETTLE_TRANSITION = `inset ${CHROME_SETTLE_MS}ms ease-out, border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
|
|
8
8
|
const SELECTION_BOX_SETTLE_TRANSITION = `border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
|
|
9
9
|
const CHROME_HANDLE_SETTLE_TRANSITION = `width ${CHROME_SETTLE_MS}ms ease-out, height ${CHROME_SETTLE_MS}ms ease-out, border-width ${CHROME_SETTLE_MS}ms ease-out, top ${CHROME_SETTLE_MS}ms ease-out, bottom ${CHROME_SETTLE_MS}ms ease-out, left ${CHROME_SETTLE_MS}ms ease-out, right ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
|
|
10
|
-
// Frame header (name + "Interact" button) is counter-scaled via
|
|
11
|
-
// transform to stay a fixed screen size; ease that scale on zoom-settle. opacity
|
|
12
|
-
// is included so the button's hover-fade (transition-opacity) keeps working.
|
|
13
|
-
const CHROME_LABEL_SETTLE_TRANSITION = `transform ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
|
|
14
10
|
|
|
15
11
|
export function getChromeBorderTransition(chromeSettling: boolean) {
|
|
16
12
|
return chromeSettling
|
|
@@ -28,8 +24,16 @@ export function getChromeHandleTransition(chromeSettling: boolean) {
|
|
|
28
24
|
: CHROME_OPACITY_TRANSITION;
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Frame header (name + "Interact" button). It is counter-scaled by transform to
|
|
29
|
+
* stay a fixed screen size, and that counter-scale now tracks the live zoom
|
|
30
|
+
* every gesture frame through the `--an-chrome-scale` custom property — so
|
|
31
|
+
* unlike the border/handle/selection chrome above there is no settle-time
|
|
32
|
+
* snap-back to ease, and no `chromeSettling` branch. Transitioning `transform`
|
|
33
|
+
* here would actively lag the label behind the canvas whenever a new zoom tick
|
|
34
|
+
* lands inside the settle window. Opacity is still eased so the button's
|
|
35
|
+
* hover-fade keeps working.
|
|
36
|
+
*/
|
|
37
|
+
export function getChromeLabelTransition() {
|
|
38
|
+
return CHROME_OPACITY_TRANSITION;
|
|
35
39
|
}
|
|
@@ -39,13 +39,30 @@ export const OVERVIEW_CULLING_ENABLED = true;
|
|
|
39
39
|
* recomputes. */
|
|
40
40
|
export const OVERVIEW_CULLING_OVERSCAN_FACTOR = 1.5;
|
|
41
41
|
|
|
42
|
-
/** Maximum number of evictable overview
|
|
43
|
-
*
|
|
42
|
+
/** Maximum number of evictable overview SCREENS kept mounted at once.
|
|
43
|
+
*
|
|
44
|
+
* The pool is budgeted in screens rather than iframes because a screen is the
|
|
45
|
+
* unit the user perceives: a board where every screen carries breakpoint
|
|
46
|
+
* previews costs several browsing contexts per screen, and charging those
|
|
47
|
+
* against one flat iframe budget shrank the pool to a third of its intended
|
|
48
|
+
* size. Past that point every committed pan/zoom re-ranked the in-viewport set
|
|
49
|
+
* by distance and admitted a different subset, so screens were destroyed and
|
|
50
|
+
* re-created — a document reload, i.e. a visible flash — on ordinary camera
|
|
51
|
+
* movement. Generating a variant set installs a design-wide breakpoint set, so
|
|
52
|
+
* this regressed the moment variants existed.
|
|
53
|
+
*
|
|
44
54
|
* Interaction-protected screens are the sole safety exception: if the user
|
|
45
55
|
* explicitly selects more than this budget, preserving their live editor
|
|
46
56
|
* state wins until that interaction ends, after which the pool contracts on
|
|
47
57
|
* the next culling pass. */
|
|
48
|
-
export const
|
|
58
|
+
export const OVERVIEW_LIVE_SCREEN_BUDGET = 32;
|
|
59
|
+
|
|
60
|
+
/** Hard ceiling on total mounted browsing contexts, independent of the screen
|
|
61
|
+
* budget above. This is the memory backstop the original budget existed for —
|
|
62
|
+
* a long tour of a 100+ screen board still cannot accumulate contexts without
|
|
63
|
+
* bound — set high enough that a normal breakpoint-bearing board is limited by
|
|
64
|
+
* OVERVIEW_LIVE_SCREEN_BUDGET instead of by this. */
|
|
65
|
+
export const OVERVIEW_LIVE_IFRAME_CEILING = 96;
|
|
49
66
|
|
|
50
67
|
export type ScreenCullTier =
|
|
51
68
|
/** Full content (iframe/DesignCanvas) is mounted and rendered normally. */
|
|
@@ -127,7 +144,8 @@ export function computeBoundedScreenCullState({
|
|
|
127
144
|
everVisibleScreenIds,
|
|
128
145
|
lastVisibleEpochByScreenId,
|
|
129
146
|
accessEpoch,
|
|
130
|
-
|
|
147
|
+
liveScreenBudget = OVERVIEW_LIVE_SCREEN_BUDGET,
|
|
148
|
+
liveIframeBudget = OVERVIEW_LIVE_IFRAME_CEILING,
|
|
131
149
|
}: {
|
|
132
150
|
candidates: readonly ScreenCullCandidate[];
|
|
133
151
|
viewport: OverscannedViewportBounds | null;
|
|
@@ -136,6 +154,7 @@ export function computeBoundedScreenCullState({
|
|
|
136
154
|
everVisibleScreenIds: ReadonlySet<string>;
|
|
137
155
|
lastVisibleEpochByScreenId: ReadonlyMap<string, number>;
|
|
138
156
|
accessEpoch: number;
|
|
157
|
+
liveScreenBudget?: number;
|
|
139
158
|
liveIframeBudget?: number;
|
|
140
159
|
}): BoundedScreenCullState {
|
|
141
160
|
if (!OVERVIEW_CULLING_ENABLED) {
|
|
@@ -187,19 +206,25 @@ export function computeBoundedScreenCullState({
|
|
|
187
206
|
if (protectedScreenIds.has(candidate.id)) add(candidate);
|
|
188
207
|
}
|
|
189
208
|
// Protected screens can temporarily exceed the normal pool. In that case
|
|
190
|
-
// no evictable
|
|
191
|
-
const
|
|
209
|
+
// no evictable screen is admitted until the interaction set shrinks again.
|
|
210
|
+
const effectiveScreenBudget = Math.max(
|
|
211
|
+
Math.max(0, Math.floor(liveScreenBudget)),
|
|
212
|
+
nextLive.size,
|
|
213
|
+
);
|
|
214
|
+
const effectiveIframeBudget = Math.max(
|
|
192
215
|
Math.max(0, Math.floor(liveIframeBudget)),
|
|
193
216
|
mountedIframeCount,
|
|
194
217
|
);
|
|
195
218
|
const tryAddWithinBudget = (candidate: ScreenCullCandidate) => {
|
|
196
219
|
if (nextLive.has(candidate.id)) return;
|
|
220
|
+
if (nextLive.size + 1 > effectiveScreenBudget) return;
|
|
197
221
|
if (
|
|
198
|
-
mountedIframeCount + normalizedIframeCount(candidate)
|
|
199
|
-
|
|
222
|
+
mountedIframeCount + normalizedIframeCount(candidate) >
|
|
223
|
+
effectiveIframeBudget
|
|
200
224
|
) {
|
|
201
|
-
|
|
225
|
+
return;
|
|
202
226
|
}
|
|
227
|
+
add(candidate);
|
|
203
228
|
};
|
|
204
229
|
|
|
205
230
|
const visibleCandidates = candidates
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BREAKPOINT_FRAME_GAP,
|
|
3
|
+
getResponsiveGroupWidth,
|
|
4
|
+
visibleBreakpointWidths,
|
|
5
|
+
} from "@shared/responsive-frame-layout";
|
|
6
|
+
|
|
1
7
|
import { DEVICE_FRAME_VIEWPORTS, type DeviceFrameType } from "../types";
|
|
2
8
|
import { SURFACE_PADDING } from "./overview-layout";
|
|
3
9
|
import type { FrameGeometry, FrameGeometryById, Point } from "./types";
|
|
@@ -5,7 +11,8 @@ import type { FrameGeometry, FrameGeometryById, Point } from "./types";
|
|
|
5
11
|
const SCREEN_WIDTH = 320;
|
|
6
12
|
const SCREEN_GAP = 56;
|
|
7
13
|
const FRAME_LABEL_HEIGHT = 28;
|
|
8
|
-
|
|
14
|
+
|
|
15
|
+
export { BREAKPOINT_FRAME_GAP, visibleBreakpointWidths };
|
|
9
16
|
|
|
10
17
|
export interface BoundsRect {
|
|
11
18
|
left: number;
|
|
@@ -26,26 +33,6 @@ type ResponsiveLayoutScreen = {
|
|
|
26
33
|
layoutGroupId?: string;
|
|
27
34
|
};
|
|
28
35
|
|
|
29
|
-
/** Drops any breakpoint whose width equals the primary frame's own width — a
|
|
30
|
-
* redundant duplicate of the base — also cleaning up designs authored before
|
|
31
|
-
* the default set excluded the primary width. */
|
|
32
|
-
export function visibleBreakpointWidths(
|
|
33
|
-
breakpointWidths: readonly number[] | undefined,
|
|
34
|
-
primaryWidthPx: number | undefined,
|
|
35
|
-
): number[] {
|
|
36
|
-
const deduped = Array.from(
|
|
37
|
-
new Set(
|
|
38
|
-
(breakpointWidths ?? []).filter(
|
|
39
|
-
(width) => Number.isFinite(width) && width > 0,
|
|
40
|
-
),
|
|
41
|
-
),
|
|
42
|
-
);
|
|
43
|
-
if (primaryWidthPx === undefined || !Number.isFinite(primaryWidthPx)) {
|
|
44
|
-
return deduped;
|
|
45
|
-
}
|
|
46
|
-
return deduped.filter((width) => Math.abs(width - primaryWidthPx) > 1);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
36
|
/** Minimum height for a frame of the given width — one device viewport tall
|
|
50
37
|
* before it grows to content. Keep in sync with deviceViewportHeight in
|
|
51
38
|
* content-size-report.ts. */
|
|
@@ -85,12 +72,11 @@ export function getResponsiveScreenGroupSize(
|
|
|
85
72
|
: (width * sourceHeight) / sourceWidth;
|
|
86
73
|
};
|
|
87
74
|
return {
|
|
88
|
-
width:
|
|
89
|
-
baseWidth
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
),
|
|
75
|
+
width: getResponsiveGroupWidth({
|
|
76
|
+
primaryWidth: baseWidth,
|
|
77
|
+
scale,
|
|
78
|
+
visibleWidths: breakpoints,
|
|
79
|
+
}),
|
|
94
80
|
height: Math.max(
|
|
95
81
|
baseHeight,
|
|
96
82
|
...breakpoints.map((width) => breakpointNaturalHeight(width) * scale),
|
|
@@ -225,7 +225,11 @@ export interface MultiScreenCanvasProps {
|
|
|
225
225
|
* Called when the user clicks the + affordance on a screen's breakpoint
|
|
226
226
|
* row to add the next standard breakpoint width (390 / 768 / 1280).
|
|
227
227
|
*/
|
|
228
|
-
|
|
228
|
+
/** Adds a breakpoint width to the DESIGN. A design has one active breakpoint
|
|
229
|
+
* set in v1, so this deliberately takes no screen id: every screen renders
|
|
230
|
+
* the same widths, and a per-screen parameter here only ever promised
|
|
231
|
+
* scoping the action cannot deliver. */
|
|
232
|
+
onAddBreakpoint?: (widthPx: number) => void;
|
|
229
233
|
/**
|
|
230
234
|
* Called when the user clicks a breakpoint frame header to make it the
|
|
231
235
|
* active edit scope.
|
|
@@ -838,6 +842,9 @@ export type PendingWheelGesture =
|
|
|
838
842
|
| {
|
|
839
843
|
mode: "zoom";
|
|
840
844
|
deltaY: number;
|
|
845
|
+
/** Trackpad pinch rather than a discrete mouse notch — the two use
|
|
846
|
+
* different sensitivities, so accumulation must not mix them. */
|
|
847
|
+
pinch: boolean;
|
|
841
848
|
cursor: Point;
|
|
842
849
|
clientX: number;
|
|
843
850
|
clientY: number;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wheel/pinch → zoom-factor conversion.
|
|
3
|
+
*
|
|
4
|
+
* The two input devices are not comparable and must not share a sensitivity.
|
|
5
|
+
* A mouse wheel emits coarse, discrete notches (one detent ≈ 100px in Chrome's
|
|
6
|
+
* pixel delta mode); a trackpad pinch emits fine, often fractional deltas at a
|
|
7
|
+
* far higher rate. Feeding both through one `exp(-deltaY * 0.01)` meant a
|
|
8
|
+
* single mouse notch multiplied zoom by e¹ ≈ 2.72× — which is what made canvas
|
|
9
|
+
* zoom feel uncontrollable next to Figma's ~1.1× per notch.
|
|
10
|
+
*
|
|
11
|
+
* The clamp applies to the resulting FACTOR, not to the accumulated delta.
|
|
12
|
+
* Clamping the delta made the step frame-rate dependent: deltas accumulate
|
|
13
|
+
* between animation frames, so a dropped frame silently doubled the distance
|
|
14
|
+
* travelled. Clamping the factor means a dropped frame costs smoothness, never
|
|
15
|
+
* distance.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** One mouse-wheel detent in `deltaMode === 0` pixels (Chrome/Safari). */
|
|
19
|
+
export const MOUSE_WHEEL_NOTCH_PX = 100;
|
|
20
|
+
/** Zoom multiplier for one full mouse-wheel notch, matching Figma's feel. */
|
|
21
|
+
export const ZOOM_STEP_PER_NOTCH = 1.1;
|
|
22
|
+
/** Multiplier exponent per pixel of trackpad pinch delta. */
|
|
23
|
+
export const PINCH_ZOOM_SENSITIVITY = 0.0075;
|
|
24
|
+
/** Ceiling on a single frame's zoom change, in either direction. */
|
|
25
|
+
export const MAX_ZOOM_FACTOR_PER_FRAME = 1.6;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a ctrl/meta-modified wheel delta came from a trackpad pinch rather
|
|
29
|
+
* than a mouse wheel. Browsers report macOS pinch as a synthetic ctrl+wheel
|
|
30
|
+
* with small, frequently fractional pixel deltas, while a real wheel detent is
|
|
31
|
+
* a whole number near the notch size. Only meaningful for `deltaMode === 0`;
|
|
32
|
+
* line/page modes are always discrete wheels and callers must not ask.
|
|
33
|
+
*/
|
|
34
|
+
export function isPinchZoomDelta(deltaY: number): boolean {
|
|
35
|
+
if (!Number.isFinite(deltaY)) return false;
|
|
36
|
+
return (
|
|
37
|
+
!Number.isInteger(deltaY) || Math.abs(deltaY) < MOUSE_WHEEL_NOTCH_PX / 2
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Unclamped zoom multiplier for a wheel/pinch delta. Negative delta (scroll
|
|
42
|
+
* up / pinch open) zooms in, matching the browser's wheel convention. */
|
|
43
|
+
export function zoomFactorForWheelDelta(
|
|
44
|
+
deltaY: number,
|
|
45
|
+
pinch: boolean,
|
|
46
|
+
): number {
|
|
47
|
+
if (!Number.isFinite(deltaY) || deltaY === 0) return 1;
|
|
48
|
+
return pinch
|
|
49
|
+
? Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY)
|
|
50
|
+
: Math.pow(ZOOM_STEP_PER_NOTCH, -deltaY / MOUSE_WHEEL_NOTCH_PX);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Bounds one frame's zoom change symmetrically in multiplicative space, so
|
|
54
|
+
* zooming in and back out by the same clamped amount is a round trip. */
|
|
55
|
+
export function clampZoomFactor(factor: number): number {
|
|
56
|
+
if (!Number.isFinite(factor) || factor <= 0) return 1;
|
|
57
|
+
return Math.min(
|
|
58
|
+
MAX_ZOOM_FACTOR_PER_FRAME,
|
|
59
|
+
Math.max(1 / MAX_ZOOM_FACTOR_PER_FRAME, factor),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The zoom multiplier to apply for one flushed gesture frame. */
|
|
64
|
+
export function resolveZoomFactor(deltaY: number, pinch: boolean): number {
|
|
65
|
+
return clampZoomFactor(zoomFactorForWheelDelta(deltaY, pinch));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Anchor for an externally driven zoom change. Only hold the frame centre when
|
|
69
|
+
* it is on screen: a frame taller than the viewport has its centre off-screen,
|
|
70
|
+
* and holding an invisible point fixed pushes the visible part out of view. */
|
|
71
|
+
export function resolveExternalZoomAnchor(args: {
|
|
72
|
+
frameCenter: { x: number; y: number } | null;
|
|
73
|
+
surfaceSize: { width: number; height: number };
|
|
74
|
+
}): { x: number; y: number } {
|
|
75
|
+
const { frameCenter, surfaceSize } = args;
|
|
76
|
+
const viewportCenter = {
|
|
77
|
+
x: surfaceSize.width / 2,
|
|
78
|
+
y: surfaceSize.height / 2,
|
|
79
|
+
};
|
|
80
|
+
if (!frameCenter) return viewportCenter;
|
|
81
|
+
if (
|
|
82
|
+
!Number.isFinite(frameCenter.x) ||
|
|
83
|
+
!Number.isFinite(frameCenter.y) ||
|
|
84
|
+
surfaceSize.width <= 0 ||
|
|
85
|
+
surfaceSize.height <= 0
|
|
86
|
+
) {
|
|
87
|
+
return viewportCenter;
|
|
88
|
+
}
|
|
89
|
+
const onScreen =
|
|
90
|
+
frameCenter.x >= 0 &&
|
|
91
|
+
frameCenter.x <= surfaceSize.width &&
|
|
92
|
+
frameCenter.y >= 0 &&
|
|
93
|
+
frameCenter.y <= surfaceSize.height;
|
|
94
|
+
return onScreen ? frameCenter : viewportCenter;
|
|
95
|
+
}
|