Skip to content

Commit 8f41b2d

Browse files
fix(flow designer): a flow wider than the canvas opens zoomed out to fit its width (#27978)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:流程画布打开时,如果流程图比画布宽(例如手机宽度下有分支的流程),会先缩小到宽度放得下(最低 40%)并居中;放得下的流程仍按 100% 打开,打开时从不放大。 - 为什么改:HotCRM 浏览器走查(#27356 第 22 行)在手机宽度下看到流程画布按 100% 打开,两侧的分支节点被切掉。 - 风险与回滚:只改画布挂载时的一次缩放计算,不改缩放按钮、适应视图和宽度变化时的重新适配;回滚就是 revert 本 PR。 - 先看:`FlowCanvas.tsx` 里挂载时居中的那个 effect(`#27356 row 22` 注释处)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.openFit-27356.test.tsx`,应见 4 passed。 - 需要你做的:无,席位落地。 Part of #27356 (row 22, hotcrm#2320 Studio S11: the flow canvas opens fitted at phone width) Clause-②: no Metadata-protocol: none ## What changes - **A diagram wider than the canvas opens fitted to its width.** `FlowCanvas` framed every flow at 100%, centred, on mount. On a phone-width canvas a branching flow's outer branch nodes hung off both edges until the author pressed fit-to-view. The mount framing now takes `z = max(MIN_ZOOM, min(1, (canvasWidth - 32) / diagramWidth))` and centres the diagram at that zoom. - It never zooms in: a diagram that fits opens at 100%, as before. - Width only: a tall linear flow still opens at 1:1, top-aligned, and the author scrolls down. This keeps the "familiar 1:1 on open" promise the effect's comment makes for every diagram that fits across. - A 0-wide (hidden) canvas opens at 100%, the same "nothing to fit" reading the width-change re-fit (objectui#11795) uses. - Unchanged: the zoom buttons, fit-to-view, the re-fit on a canvas width change, and the `addNode` / `insertOnEdge` call sites (another card is changing those two). - The surface: `views/metadata-admin/previews/FlowCanvas.tsx` is outside this order's declared Studio surface; the seat widened it to this one file for row 22. ## Tests - New `FlowCanvas.openFit-27356.test.tsx` (4 tests) on the real canvas, with the viewport's box and `ResizeObserver` doubled as in `FlowCanvas.resizeFit-11795`: a three-branch flow at 390px opens below 100%, centred, its whole width inside the canvas, and the zoom readout shows that zoom; the same flow at 1400px opens at 100%, centred; a linear flow that fits 390px opens at 100%; a 0-wide canvas opens at 100%. <details><summary>Readings (head 881dbf5, run on bf7e1c77, the same tree before a message-only amend; branch rebuilt from origin/main bacb3e6 after #27927 merged; install and app-shell closure rebuilt)</summary> - New file: 4/4 passed. - Every ui test file naming `FlowCanvas` (19 files, including #27930's `FlowCanvas.updateRecordSeed-27834`): 19 passed, 293 passed. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0 (lock VERDICT command-exit 0). - Ablation through `scripts/ablation-replace.mjs` (anchor 1 to 0): the mount zoom back to `const z = 1;`: 1 failed / 3 passed (the 390px branching case red); restored blob = HEAD blob `776392e9d921`, `git diff HEAD` empty. vitest resolves `@object-ui/*` to source, so no rebuild leg. - `pnpm --dir ui run check:i18n-drift`: no en value changed. </details> ## Acceptance notes - **Row 22's second half is ALREADY DONE on our side.** The record cards' lines already come from the object's `highlightFields` (ADR-0085): `plugin-grid` `objectGridGenerateColumns` and `plugin-list` `ListView` lead with them when the object declares them, and Studio's object Settings tab edits them. "Account Name (Normalized)" is a HotCRM field that is not `hidden`, on an object that declares no `highlightFields`, so the default branch shows it. The fix is HotCRM's metadata (declare `highlightFields`, or mark the field hidden), outside this repo. - The 40% floor is the canvas's existing `MIN_ZOOM`. A diagram wider than 2.5 canvas widths still overflows at 40%; it was not measured on a real HotCRM flow. - Measured in happy-dom, which lays nothing out: the canvas and diagram widths are the box the test sets and the layout's computed size, not a rendered phone. ## Verification On head `881dbf54` (tree identical to `bf7e1c77`, where they ran): `dispatch-gates --commands` derived 34 families for this diff; all 34 were run locally, each exit 0, and `--ran` reconciled 34 run, 0 NOT-MEASURED, 0 UNRUN. `check:new-line-citations`: 0 new. CI check-runs were in progress when this was opened. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZrsFMFmWFsxTaq2Bu)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4826f8 commit 8f41b2d

3 files changed

Lines changed: 196 additions & 3 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(flow designer): a flow wider than the canvas opens zoomed out to fit its width
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The flow canvas opened every flow at 100%, centred. On a narrow canvas, such as Studio's Automations pillar at phone width, a branching flow's outer branches were cut off at both edges until the author pressed fit-to-view. Now a diagram wider than the canvas opens zoomed out until its width fits (never below the canvas's 40% floor), still centred. A diagram that already fits opens at 100% as before. The canvas never zooms in when it opens, and only width counts, so a tall linear flow still opens at 1:1. Zoom, fit-to-view and the re-fit on a canvas width change are unchanged.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #27356 row 22 — the flow canvas opens with a diagram wider than itself
5+
* zoomed out until its width fits, and never zooms a diagram in past 100%.
6+
*
7+
* Reported from HotCRM at phone width: a branching flow opened at 100% with its
8+
* outer branch nodes cut off at both edges.
9+
*
10+
* The canvas is the real one. happy-dom lays nothing out, so the viewport's
11+
* `clientWidth` / `clientHeight` are read from a box this file sets, and
12+
* `ResizeObserver` is a double whose notifications the tests deliver, the way
13+
* a browser delivers them: once after `observe`.
14+
*/
15+
16+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
17+
import { render, screen, cleanup, act } from '@testing-library/react';
18+
import { FlowCanvas } from './FlowCanvas';
19+
import type { FlowDesignerEdge, FlowDesignerNode } from './flow-canvas-layout';
20+
21+
// The palette's engine overlay answers "engine absent" (its offline answer).
22+
vi.stubGlobal(
23+
'fetch',
24+
vi.fn(async () => new Response('not found', { status: 404 })),
25+
);
26+
27+
const box = { w: 0, h: 0 };
28+
const observers = new Set<{ cb: ResizeObserverCallback; self: ResizeObserver }>();
29+
class ResizeObserverDouble {
30+
private entry: { cb: ResizeObserverCallback; self: ResizeObserver };
31+
constructor(cb: ResizeObserverCallback) {
32+
this.entry = { cb, self: this as unknown as ResizeObserver };
33+
}
34+
observe() {
35+
observers.add(this.entry);
36+
}
37+
unobserve() {
38+
observers.delete(this.entry);
39+
}
40+
disconnect() {
41+
observers.delete(this.entry);
42+
}
43+
}
44+
/** Deliver a notification to every live observer, as a browser does after `observe`. */
45+
function notify() {
46+
act(() => {
47+
for (const o of [...observers]) o.cb([], o.self);
48+
});
49+
}
50+
51+
/** The prototype that owns `name` on the viewport element. */
52+
function ownerOf(name: 'clientWidth' | 'clientHeight'): object {
53+
let p: object | null = HTMLDivElement.prototype;
54+
while (p && !Object.getOwnPropertyDescriptor(p, name)) p = Object.getPrototypeOf(p);
55+
if (!p) throw new Error(`no prototype defines ${name}`);
56+
return p;
57+
}
58+
59+
let realResizeObserver: typeof ResizeObserver | undefined;
60+
beforeEach(() => {
61+
box.w = 0;
62+
box.h = 0;
63+
observers.clear();
64+
realResizeObserver = globalThis.ResizeObserver;
65+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverDouble;
66+
const isViewport = (el: Element) => el.getAttribute('role') === 'application';
67+
vi.spyOn(ownerOf('clientWidth') as HTMLElement, 'clientWidth', 'get').mockImplementation(function (this: Element) {
68+
return isViewport(this) ? box.w : 0;
69+
});
70+
vi.spyOn(ownerOf('clientHeight') as HTMLElement, 'clientHeight', 'get').mockImplementation(function (this: Element) {
71+
return isViewport(this) ? box.h : 0;
72+
});
73+
});
74+
75+
afterEach(() => {
76+
cleanup();
77+
vi.restoreAllMocks();
78+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = realResizeObserver;
79+
localStorage.clear();
80+
});
81+
82+
const linear = (): { nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] } => ({
83+
nodes: [
84+
{ id: 'start', type: 'start', label: 'Start' },
85+
{ id: 'assign', type: 'assignment', label: 'Set owner' },
86+
{ id: 'end', type: 'end', label: 'End' },
87+
],
88+
edges: [
89+
{ id: 'e1', source: 'start', target: 'assign' },
90+
{ id: 'e2', source: 'assign', target: 'end' },
91+
],
92+
});
93+
94+
/** A decision with three branches: three node columns side by side. */
95+
const branching = (): { nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] } => ({
96+
nodes: [
97+
{ id: 'start', type: 'start', label: 'Start' },
98+
{ id: 'gate', type: 'decision', label: 'Route lead' },
99+
{ id: 'a', type: 'assignment', label: 'Hot' },
100+
{ id: 'b', type: 'assignment', label: 'Warm' },
101+
{ id: 'c', type: 'assignment', label: 'Cold' },
102+
{ id: 'end', type: 'end', label: 'End' },
103+
],
104+
edges: [
105+
{ id: 'e1', source: 'start', target: 'gate' },
106+
{ id: 'e2', source: 'gate', target: 'a', label: 'Hot' },
107+
{ id: 'e3', source: 'gate', target: 'b', label: 'Warm' },
108+
{ id: 'e4', source: 'gate', target: 'c', label: 'Cold' },
109+
{ id: 'e5', source: 'a', target: 'end' },
110+
{ id: 'e6', source: 'b', target: 'end' },
111+
{ id: 'e7', source: 'c', target: 'end' },
112+
],
113+
});
114+
115+
function mountAt(w: number, h: number, draft: { nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] }) {
116+
box.w = w;
117+
box.h = h;
118+
render(
119+
<FlowCanvas
120+
nodes={draft.nodes}
121+
edges={draft.edges}
122+
editable
123+
designMode
124+
selectedId={null}
125+
onSelect={() => {}}
126+
onPatch={() => {}}
127+
/>,
128+
);
129+
// The browser's first notification after `observe`, at the width just framed.
130+
notify();
131+
}
132+
133+
/** The pan and zoom the canvas applied to the diagram, and the diagram's own width, read off its layer. */
134+
function view(): { x: number; zoom: number; width: number } {
135+
const layer = screen.getByRole('application').firstElementChild as HTMLElement;
136+
const m = /translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/.exec(layer.style.transform);
137+
expect(m, `diagram transform: ${layer.style.transform}`).not.toBeNull();
138+
return { x: Number(m![1]), zoom: Number(m![3]), width: parseFloat(layer.style.width) };
139+
}
140+
141+
describe('FlowCanvas — a diagram wider than the canvas opens fitted to its width (#27356 row 22)', () => {
142+
it('a branching flow at phone width opens zoomed out, centred, with every branch inside the canvas', () => {
143+
mountAt(390, 700, branching());
144+
145+
const v = view();
146+
expect(v.width).toBeGreaterThan(390);
147+
expect(v.zoom).toBeLessThan(1);
148+
expect(v.x).toBeGreaterThanOrEqual(0);
149+
expect(v.x + v.width * v.zoom).toBeLessThanOrEqual(390);
150+
expect(screen.getByText(`${Math.round(v.zoom * 100)}%`)).toBeTruthy();
151+
});
152+
153+
it('the same flow on a wide canvas opens at 100%, centred', () => {
154+
mountAt(1400, 800, branching());
155+
156+
const v = view();
157+
expect(v.zoom).toBe(1);
158+
expect(v.x).toBe((1400 - v.width) / 2);
159+
});
160+
161+
it('a flow that already fits a phone-width canvas opens at 100%, not zoomed in', () => {
162+
mountAt(390, 700, linear());
163+
164+
const v = view();
165+
expect(v.width).toBeLessThan(390);
166+
expect(v.zoom).toBe(1);
167+
expect(screen.getByText('100%')).toBeTruthy();
168+
});
169+
170+
it('a hidden canvas (0 wide) opens at 100%, not fitted into nothing', () => {
171+
mountAt(0, 0, branching());
172+
173+
expect(view().zoom).toBe(1);
174+
});
175+
});

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -752,13 +752,21 @@ export function FlowCanvas({
752752
// the toolbar. Deliberately mount-only (not re-run on every `size` change):
753753
// re-centering on every node add/drag would yank the viewport out from under
754754
// an actively editing user.
755+
//
756+
// #27356 row 22 — a diagram wider than the canvas (a branching flow at phone
757+
// width) opens zoomed out until its width fits, never below MIN_ZOOM, so its
758+
// outer branches are not cut off at both edges. Never zoomed in past 100%;
759+
// width only, so a tall linear flow still opens at 1:1 and scrolls down. A 0
760+
// width is a hidden canvas: nothing to fit.
755761
React.useEffect(() => {
756762
const vp = viewportRef.current;
757763
if (!vp) return;
758-
setZoom(1);
764+
const width = vp.clientWidth;
765+
const z = width > 0 ? Math.max(MIN_ZOOM, Math.min(1, (width - 32) / size.width)) : 1;
766+
setZoom(z);
759767
setPan({
760-
x: (vp.clientWidth - size.width) / 2,
761-
y: Math.max(16, (vp.clientHeight - size.height) / 2),
768+
x: (width - size.width * z) / 2,
769+
y: Math.max(16, (vp.clientHeight - size.height * z) / 2),
762770
});
763771
framedWidthRef.current = vp.clientWidth;
764772
// eslint-disable-next-line react-hooks/exhaustive-deps

0 commit comments

Comments
 (0)