Skip to content

Commit 70f1b27

Browse files
fix(app-shell): Studio on a smaller or resized window (objectui#11795) (#12015)
Fixes #11795 Clause-②: no Studio stays a desktop tool (triage direction `6043019702`): the flow canvas fits to view on resize, below the width where the inspector overlaps the canvas it opens as a drawer, and a phone gets a one-line hint. All of it is `@object-ui/app-shell`'s Studio layout and flow canvas; no contract, export or published language pack changes. ## What changed 1. **The flow canvas re-fits when its own width changes** (`FlowCanvas.tsx`). A `ResizeObserver` on the canvas viewport re-frames the diagram when the viewport's width differs from the width it was last framed at: centred, zoomed out only as far as the whole flow needs, never past 100% (the scale a flow opens at, so a 3-node flow is not zoomed up to 145% by a wider window). The mount centering is untouched. - **Width only, never height.** The size changes an edit causes are vertical: a held-edit notice or a refused-save strip landing above the canvas after an autosave, a Problems list growing under it below `lg`. Re-fitting on those would move the viewport right after a node edit, which the mount centering's comment forbids. A window resize, the chat dock and the side panels beside the canvas all change its width. - A node add, drag or edit changes the diagram, not the canvas: the observer is re-armed at the width already framed and moves nothing. A 0 width (hidden canvas) is skipped. 2. **On a narrow Automations row the Configuration aside opens as a drawer** (`StudioDesignSurface.tsx` AutomationsPillar layout, `wideViewport.ts`). When the pillar's own row is narrower than `AUTOMATIONS_CONFIG_FOLD_WIDTH`, the fixed 288px aside is not rendered at all (it no longer reserves its width), and a selection opens the same configuration body in the shared `Sheet` over the canvas. Closing the drawer clears the selection, as the aside's ✕ does. Wider rows keep the aside exactly as before. The row is measured with `useIsNarrowerThan` (a `ResizeObserver` on the row, read before paint); a row that measures 0 is unmeasured and keeps the aside. 3. **A one-line desktop hint on a phone** (`StudioDesignSurface` header). Below `md` (`useIsMobile`), one `role="note"` line under the Studio header reads `engine.studio.desktopHint`: "Studio is built for a desktop screen." / "Studio 专为桌面屏幕设计。" New en and zh rows in `metadata-admin/i18n.ts` under the existing `engine.studio.` prefix. ## The threshold, from measurement The overlap is decided by the pillar's own row, not by the window: the Studio chat dock sits outside that row and takes 340 to 420px of the window when it is open. One window width therefore answers both ways (1024px: canvas about 460px with no dock, about 40px with it), which a viewport breakpoint cannot express. `AUTOMATIONS_CONFIG_FOLD_WIDTH` = flow rail 208 (`w-52`) + chrome between the row and the canvas viewport 68 (main `p-4`, framed card `p-4` and borders; read in Chromium) + aside 288 (`w-72`) + one flow column at 100% (`NODE_W` + 2 x `PADDING` = 296, the width a linear flow draws at) = 860. Below it the aside leaves less than one flow column. Every row below `md` is narrower than 860, so a phone always folds. ## Browser readings, before and after (Chromium 141, the Automations pillar) The real `AutomationsPillar` with the real registered `FlowPreview` and `FlowInspector`, served by the console's Vite from this worktree, over a fetch stub that answers one 3-node flow (start, assignment, end). "dock" is a 420px column beside the pillar, the chat dock's default width. The harness files were temporary and are not in this PR. | window | before: canvas width / aside | after: canvas width / aside | |---|---|---| | 390 | 34px beside the 288px aside; Start card off both edges | 322px, no aside; Start card inside | | 1024 | 460px / aside | 460px / aside (row 1024, unchanged) | | 1024 + dock | 40px / aside | 328px / drawer (row 604) | | 1440 | 876px / aside | 876px / aside (unchanged) | | 1440 + dock | 456px / aside | 456px / aside (row 1020, unchanged) | | opened at 390, widened to 1024 | Start/End cards clipped at the left edge (103px off-canvas) | centred, 110px clear on each side | | then widened to 1440 | still 103px off-canvas | centred, 318px clear on each side | | 390, select Start | inspector squeezed into the aside beside a 34px canvas | the inspector in a 390px drawer | Controls, after: at 1440 a node added through the toolbar palette left the diagram's translate and scale unchanged; a node select did not move the viewport. Toggling the Variables panel at 1440 narrowed the canvas from 876 to 636px and re-fitted it, centred at 100%; at 390 the same panel stacks below the canvas (a height change) and nothing moved. ## Mechanism hypotheses, as measured 1. Canvas: confirmed mount-only. Both test environments (`dom`, `dom-heavy`) install a no-op `ResizeObserver` polyfill (`vitest.setup.base.ts`); the pins replace it with a double whose notifications they deliver and put the original back, the shape `ChartContainerImpl.settleRemount.test.tsx` uses. The claim said "viewport size changes"; this re-fits on width only, for the reason above. 2. Fold: the hypothesis that the fold decision governs Automations is false. `showFoldedTabs` and `useIsWideViewport` belong to the Interfaces pillar; Automations renders its own fixed `w-72` aside at every width, independent of them. Which pillars use which layout: - Interfaces, the console's default (no `aiSlot`): below `xl` the inspector is a center tab, from `xl` it sits beside the canvas. No overlap, so its fold decision and its rail are not edited here. - Automations: the fixed aside, the measured overlap. Changed here. - Data: its field aside renders only while a field is selected (see the notes below). - Access: no right aside. 3. Hint: `t()` from `metadata-admin/i18n.ts` with the `engine.studio.` prefix; `check:i18n-designer-parity` green. 4. Pillar bar: not the same layout fix (see the notes below), so not widened. ## Pins - `FlowCanvas.resizeFit-11795.test.tsx`: opened narrow then widened, the diagram is framed for the new width; narrowed below the diagram, it zooms out until the whole flow fits; a wider canvas does not zoom past 100%. Controls: a node add (with the re-armed observer's notification at the same width), a height-only change and a 0-wide canvas each leave the view exactly as it was. - `AutomationsPillar.narrowRow-11795.test.tsx`: one pixel under the threshold, no aside in the row, and a node click opens the configuration in a dialog named Configuration with an enabled Label field; Escape closes it and clears the selection; a row resized from 1200 to 600 folds its aside in place. Controls: at the threshold and unmeasured (0), the aside stays and the click fills it, with no dialog. - `StudioDesignSurface.desktopHint-11795.test.tsx`: at 390 the hint renders once, as a note, directly under the header; at 768 it does not. ## Reverse verification (each through `ablation-replace`, committed tree, restore proven) - Resize hook: `observer.observe(vp);` replaced so the observer never observes. Blob `d42d997b917a` to `42476ad28aec`; the resize file read `Tests 3 failed | 3 passed (6)`, the three re-fit pins red and the three controls green. Restored: blob == HEAD (`d42d997b917a`), `git diff HEAD` empty. - Fold: `useIsNarrowerThan(layoutRowRef, AUTOMATIONS_CONFIG_FOLD_WIDTH)` replaced with a 0 threshold. The pillar file read `Tests 3 failed | 2 passed (5)`, the three narrow-row pins red and the two controls green. Restored: blob == HEAD (`ff0517aa737d`), `git diff HEAD` empty. ## Local verification (HEAD `54dc0e51a`) - `pnpm exec vitest run packages/app-shell/`: `Test Files 1206 passed | 1 skipped (1207)`, `Tests 11715 passed | 9 skipped (11724)`. - `pnpm --filter @object-ui/app-shell type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`, after building the dependency closure): exit 0. `tsc -p tsconfig.test.json --listFilesOnly` lists all three new test files. - `pnpm exec eslint` on the seven touched files: 0 errors; the 15 warnings all sit on lines this diff does not add. - `check:control-bytes`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`, `check:i18n-designer-parity`, `check:new-line-citations`, `check:vi-mock-inherit`, `check:installed-pin-claims`, `check:doc-types`, and `scripts/check-changeset-presence.mjs` / `-no-major` / `-fixed` / `-overwrite`: all exit 0. - Entry type graph: after `pnpm --filter @object-ui/app-shell build`, neither `dist/index.d.ts` nor `dist/views/studio-design/StudioDesignSurface.d.ts` names `wideViewport`, `useIsNarrowerThan` or `AUTOMATIONS_CONFIG_FOLD_WIDTH`; the only added exports are in `wideViewport.ts`, which the entry does not reach. Hence `Clause-②: no` and a `patch` changeset. **After the merge, on `28566ca79`:** - `pnpm --filter @object-ui/app-shell type-check`: exit 0. - The narrowed run (`studio-design/`, `metadata-admin/previews/`, `metadata-admin/__tests__/` and the objectui#11939 entry pin): `Test Files 278 passed (278)`, `Tests 2612 passed (2612)`. - The light gates exit 0. - CI: 43 runs, 40 success and 3 skipped. CI ran the whole suite in eight test shards, all green. ## Acceptance notes - **objectui#11982 has landed** as `4babf4035`, and this branch merged it (`28566ca79`). `Bundle Analysis` on `28566ca79` passes: the eager closure is 3160.1 KB gzip (289 chunks) against a 3204.6 KB budget, and the main entry chunk is 71.6 KB of 350 KB. The first-load cost here is in the lazily loaded Studio chunk (`StudioRoute` loads `StudioDesignSurface` with `lazy()`), plus `wideViewport.ts` now importing two constants from the dependency-free `flow-canvas-layout.ts`. - **Pillar bar at 390px** still scrolls sideways. The Studio header's `overflow-x-auto` is deliberate (its comment: every pillar and Publish stay reachable), so a phone header is its own change, not this layout fix. Noted, not filed (no carrier). - **Data pillar** (source reading, not measured in a browser): its field aside (`w-80`) renders only while a field is selected, and at a phone width it would take most of the row. Same family as this card. Noted, not filed (no carrier). - **Interfaces with an injected `aiSlot`** (the cloud seam) keeps its classic three-zone layout with a fixed aside at every width; the comment there says the cloud edition migrates on its own schedule. Noted, not filed. - **Docs**: `packages/app-shell/README.md` (the flow canvas "Navigate" line) and `content/docs/guide/console.md` (the Studio feature table) are outside the claim's file surface, so neither is edited here. - Copy is not pinned in any test beyond resolving the key through `t()`. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d77c09 commit 70f1b27

8 files changed

Lines changed: 768 additions & 31 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Studio on a smaller or resized window (objectui#11795). Studio stays a desktop tool; three things change below desktop width:
6+
7+
- **The flow canvas re-fits when its width changes.** A window resize, the Studio chat dock opening or closing, or a side panel toggled beside the canvas now frames the diagram again: centred, and zoomed out only as far as the whole flow needs, never past 100%. A flow opened at a narrow width and then widened used to keep the narrow framing, with its Start and End cards clipped at the left edge. Adding, dragging or editing a node still leaves the viewport where it is, and so does a change in the canvas's height alone, such as a notice appearing above it.
8+
- **On a narrow Automations row the Configuration panel opens as a drawer.** When the pillar's row has no room for the 288px Configuration panel beside one flow column, the panel no longer reserves that width. Selecting a node opens its configuration in a drawer over the canvas, and closing the drawer clears the selection. The row is measured on its own, so the Studio chat dock beside it counts. Wider rows keep the panel as before.
9+
- **On a phone, one line under the Studio header says that Studio is built for a desktop screen.**

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2860,6 +2860,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
28602860
'engine.studio.deselect': 'Clear selection',
28612861
'engine.studio.toggleRail': 'Toggle sidebar',
28622862
'engine.studio.home': 'Back to home',
2863+
// objectui#11795 — the one line a phone shows under the Studio header.
2864+
'engine.studio.desktopHint': 'Studio is built for a desktop screen.',
28632865
// Pillar tab labels
28642866
'engine.fieldType.text': 'Text',
28652867
'engine.fieldType.textarea': 'Text Area',
@@ -6055,6 +6057,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
60556057
'engine.studio.deselect': '取消选择',
60566058
'engine.studio.toggleRail': '切换侧栏',
60576059
'engine.studio.home': '返回主页',
6060+
'engine.studio.desktopHint': 'Studio 专为桌面屏幕设计。',
60586061
// Pillar tab labels
60596062
'engine.fieldType.text': '单行文本',
60606063
'engine.fieldType.textarea': '多行文本',
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11795 — the flow canvas re-fits when its own width changes, and
5+
* nothing an author does to the diagram moves the viewport.
6+
*
7+
* Measured in Chromium before the fix: Studio's Automations pillar opened at a
8+
* 390px window framed the diagram for a ~34px canvas (centred, so it hung off
9+
* both edges); widened to 1024px, the canvas grew to ~460px and the diagram
10+
* stayed where the narrow box had put it, its Start/End cards clipped at the
11+
* left edge. The mount framing ran once and nothing re-ran it.
12+
*
13+
* The canvas is the real one. happy-dom lays nothing out, so the viewport's
14+
* `clientWidth` / `clientHeight` are read from a box this file sets, and
15+
* `ResizeObserver` is a double whose notifications the tests deliver, the way
16+
* a browser delivers them: once after `observe`, then on each resize.
17+
*/
18+
19+
import * as React from 'react';
20+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
21+
import { render, screen, cleanup, act } from '@testing-library/react';
22+
import { FlowCanvas } from './FlowCanvas';
23+
import { NODE_W, PADDING, type FlowDesignerEdge, type FlowDesignerNode } from './flow-canvas-layout';
24+
25+
// The palette's engine overlay answers "engine absent" (its offline answer).
26+
vi.stubGlobal(
27+
'fetch',
28+
vi.fn(async () => new Response('not found', { status: 404 })),
29+
);
30+
31+
/** The diagram's width at 100% for a linear flow: one node column plus the layout's padding. */
32+
const DIAGRAM_W = NODE_W + 2 * PADDING;
33+
34+
const box = { w: 0, h: 0 };
35+
const observers = new Set<{ cb: ResizeObserverCallback; self: ResizeObserver }>();
36+
class ResizeObserverDouble {
37+
private entry: { cb: ResizeObserverCallback; self: ResizeObserver };
38+
constructor(cb: ResizeObserverCallback) {
39+
this.entry = { cb, self: this as unknown as ResizeObserver };
40+
}
41+
observe() {
42+
observers.add(this.entry);
43+
}
44+
unobserve() {
45+
observers.delete(this.entry);
46+
}
47+
disconnect() {
48+
observers.delete(this.entry);
49+
}
50+
}
51+
/** Deliver a notification to every live observer, as a browser does after a resize. */
52+
function notify() {
53+
act(() => {
54+
for (const o of [...observers]) o.cb([], o.self);
55+
});
56+
}
57+
function resizeTo(w: number, h: number) {
58+
box.w = w;
59+
box.h = h;
60+
notify();
61+
}
62+
63+
/** The prototype that owns `name` on the viewport element. */
64+
function ownerOf(name: 'clientWidth' | 'clientHeight'): object {
65+
let p: object | null = HTMLDivElement.prototype;
66+
while (p && !Object.getOwnPropertyDescriptor(p, name)) p = Object.getPrototypeOf(p);
67+
if (!p) throw new Error(`no prototype defines ${name}`);
68+
return p;
69+
}
70+
71+
let realResizeObserver: typeof ResizeObserver | undefined;
72+
beforeEach(() => {
73+
box.w = 0;
74+
box.h = 0;
75+
observers.clear();
76+
realResizeObserver = globalThis.ResizeObserver;
77+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverDouble;
78+
const isViewport = (el: Element) => el.getAttribute('role') === 'application';
79+
vi.spyOn(ownerOf('clientWidth') as HTMLElement, 'clientWidth', 'get').mockImplementation(function (this: Element) {
80+
return isViewport(this) ? box.w : 0;
81+
});
82+
vi.spyOn(ownerOf('clientHeight') as HTMLElement, 'clientHeight', 'get').mockImplementation(function (this: Element) {
83+
return isViewport(this) ? box.h : 0;
84+
});
85+
});
86+
87+
afterEach(() => {
88+
cleanup();
89+
vi.restoreAllMocks();
90+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = realResizeObserver;
91+
localStorage.clear();
92+
});
93+
94+
type Draft = { nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] };
95+
96+
const flow = (): Draft => ({
97+
nodes: [
98+
{ id: 'start', type: 'start', label: 'Start' },
99+
{ id: 'assign', type: 'assignment', label: 'Set owner' },
100+
{ id: 'end', type: 'end', label: 'End' },
101+
],
102+
edges: [
103+
{ id: 'e1', source: 'start', target: 'assign' },
104+
{ id: 'e2', source: 'assign', target: 'end' },
105+
],
106+
});
107+
108+
/** Mount the canvas in a host that keeps the draft, as Studio does; `edit` changes it from outside. */
109+
function mountAt(w: number, h: number) {
110+
box.w = w;
111+
box.h = h;
112+
const host: { edit: (fn: (d: Draft) => Draft) => void } = { edit: () => {} };
113+
function Host() {
114+
const [draft, setDraft] = React.useState<Draft>(flow);
115+
host.edit = (fn) => act(() => setDraft(fn));
116+
return (
117+
<FlowCanvas
118+
nodes={draft.nodes}
119+
edges={draft.edges}
120+
editable
121+
designMode
122+
selectedId={null}
123+
onSelect={() => {}}
124+
onPatch={(patch) => setDraft((d) => ({ ...d, ...(patch as Partial<Draft>) }))}
125+
/>
126+
);
127+
}
128+
render(<Host />);
129+
// The browser's first notification after `observe`, at the width just framed.
130+
notify();
131+
return host;
132+
}
133+
134+
/** The pan and zoom the canvas applied to the diagram, read off its transform. */
135+
function view(): { x: number; y: number; zoom: number } {
136+
const layer = screen.getByRole('application').firstElementChild as HTMLElement;
137+
const m = /translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/.exec(layer.style.transform);
138+
expect(m, `diagram transform: ${layer.style.transform}`).not.toBeNull();
139+
return { x: Number(m![1]), y: Number(m![2]), zoom: Number(m![3]) };
140+
}
141+
142+
describe('FlowCanvas — a width change re-fits the diagram (objectui#11795)', () => {
143+
it('opened narrow and then widened, the diagram is framed for the new width, not left where the narrow box put it', () => {
144+
mountAt(34, 541);
145+
// The mount framing at 34px: centred at 100%, so the diagram's left edge is off-canvas.
146+
expect(view().x).toBeLessThan(0);
147+
148+
resizeTo(460, 570);
149+
150+
const v = view();
151+
expect(v.zoom).toBe(1);
152+
expect(v.x).toBe((460 - DIAGRAM_W) / 2);
153+
expect(v.x).toBeGreaterThanOrEqual(0);
154+
expect(v.x + DIAGRAM_W * v.zoom).toBeLessThanOrEqual(460);
155+
});
156+
157+
it('narrowed below the diagram, it zooms out until the whole flow fits, centred', () => {
158+
mountAt(800, 600);
159+
expect(view().zoom).toBe(1);
160+
161+
resizeTo(200, 600);
162+
163+
const v = view();
164+
expect(v.zoom).toBeLessThan(1);
165+
expect(v.x).toBeGreaterThanOrEqual(0);
166+
expect(v.x + DIAGRAM_W * v.zoom).toBeLessThanOrEqual(200);
167+
expect(screen.getByText(`${Math.round(v.zoom * 100)}%`)).toBeTruthy();
168+
});
169+
170+
it('a wider canvas does not zoom a small flow past the 100% it opens at', () => {
171+
mountAt(460, 570);
172+
173+
resizeTo(1400, 900);
174+
175+
expect(view()).toMatchObject({ zoom: 1, x: (1400 - DIAGRAM_W) / 2 });
176+
});
177+
});
178+
179+
describe('FlowCanvas — what an author does to the diagram never moves the viewport (objectui#11795, the control)', () => {
180+
it('a node add re-arms the observer at the width already framed, and the view stays put', () => {
181+
const host = mountAt(800, 600);
182+
const before = view();
183+
184+
host.edit((d) => ({
185+
nodes: [...d.nodes, { id: 'extra', type: 'assignment', label: 'Extra' }],
186+
edges: [...d.edges, { id: 'e3', source: 'end', target: 'extra' }],
187+
}));
188+
// The re-armed observer's first notification arrives at the same width.
189+
notify();
190+
191+
expect(document.querySelector('[data-node-id="extra"]')).not.toBeNull();
192+
expect(view()).toEqual(before);
193+
});
194+
195+
it('a height-only change — a notice landing above the canvas after an edit — does not re-fit', () => {
196+
mountAt(800, 600);
197+
const before = view();
198+
199+
resizeTo(800, 560);
200+
201+
expect(view()).toEqual(before);
202+
});
203+
204+
it('a hidden canvas (0 wide) is not fitted into nothing', () => {
205+
mountAt(800, 600);
206+
const before = view();
207+
208+
resizeTo(0, 0);
209+
210+
expect(view()).toEqual(before);
211+
});
212+
});

‎packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ export function FlowCanvas({
212212
const nodes = React.useMemo(() => withCanonicalGeometry(storedNodes), [storedNodes]);
213213

214214
const viewportRef = React.useRef<HTMLDivElement>(null);
215+
// objectui#11795 — the viewport width the diagram was last framed at (the
216+
// mount centering, or a re-fit after the canvas's width changed).
217+
const framedWidthRef = React.useRef<number | null>(null);
215218
const [zoom, setZoom] = React.useState(1);
216219
const [pan, setPan] = React.useState<Point>({ x: 0, y: 0 });
217220
// objectui#11778 — which "+" has the add-node palette open: `toolbar`,
@@ -737,9 +740,43 @@ export function FlowCanvas({
737740
x: (vp.clientWidth - size.width) / 2,
738741
y: Math.max(16, (vp.clientHeight - size.height) / 2),
739742
});
743+
framedWidthRef.current = vp.clientWidth;
740744
// eslint-disable-next-line react-hooks/exhaustive-deps
741745
}, []);
742746

747+
// objectui#11795 — re-fit when the canvas's own WIDTH changes: a window
748+
// resize, the Studio chat dock opening or closing, a side panel toggled
749+
// beside the canvas. Without it the mount framing outlived its width: opened
750+
// narrow and then widened, the diagram stayed where the narrow box put it,
751+
// its cards clipped at the left edge. The fit is the toolbar's, capped at
752+
// 100% so a small flow is not zoomed up past the scale it opens at.
753+
//
754+
// Width only, never height, which keeps the promise above: the size changes
755+
// an edit causes are vertical — a held-edit or refused-save notice landing
756+
// above the canvas, a Problems list growing under it — and must not yank the
757+
// viewport from under the author. A node add, drag or edit changes the
758+
// diagram, not the canvas, and re-arms this observer at the width it already
759+
// framed, so it moves nothing. A 0 width is a hidden canvas: nothing to fit.
760+
React.useEffect(() => {
761+
const vp = viewportRef.current;
762+
if (!vp || typeof ResizeObserver === 'undefined') return;
763+
const observer = new ResizeObserver(() => {
764+
const width = vp.clientWidth;
765+
if (width === 0 || width === framedWidthRef.current) return;
766+
framedWidthRef.current = width;
767+
const pad = 32;
768+
// 1 is under MAX_ZOOM, so only the MIN_ZOOM floor of `clampZoom` can bind.
769+
const z = Math.max(MIN_ZOOM, Math.min(1, (width - pad) / size.width, (vp.clientHeight - pad) / size.height));
770+
setZoom(z);
771+
setPan({
772+
x: (width - size.width * z) / 2,
773+
y: Math.max(16, (vp.clientHeight - size.height * z) / 2),
774+
});
775+
});
776+
observer.observe(vp);
777+
return () => observer.disconnect();
778+
}, [size.height, size.width]);
779+
743780
// Pan to center an element when the Problems panel asks to reveal it. Driven
744781
// by a changing `nonce` so re-clicking the same problem re-centers it.
745782
React.useEffect(() => {

0 commit comments

Comments
 (0)