Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/11795-studio-narrow-screens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/app-shell': patch
---

Studio on a smaller or resized window (objectui#11795). Studio stays a desktop tool; three things change below desktop width:

- **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.
- **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.
- **On a phone, one line under the Studio header says that Studio is built for a desktop screen.**
3 changes: 3 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2860,6 +2860,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.deselect': 'Clear selection',
'engine.studio.toggleRail': 'Toggle sidebar',
'engine.studio.home': 'Back to home',
// objectui#11795 — the one line a phone shows under the Studio header.
'engine.studio.desktopHint': 'Studio is built for a desktop screen.',
// Pillar tab labels
'engine.fieldType.text': 'Text',
'engine.fieldType.textarea': 'Text Area',
Expand Down Expand Up @@ -6055,6 +6057,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.deselect': '取消选择',
'engine.studio.toggleRail': '切换侧栏',
'engine.studio.home': '返回主页',
'engine.studio.desktopHint': 'Studio 专为桌面屏幕设计。',
// Pillar tab labels
'engine.fieldType.text': '单行文本',
'engine.fieldType.textarea': '多行文本',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11795 — the flow canvas re-fits when its own width changes, and
* nothing an author does to the diagram moves the viewport.
*
* Measured in Chromium before the fix: Studio's Automations pillar opened at a
* 390px window framed the diagram for a ~34px canvas (centred, so it hung off
* both edges); widened to 1024px, the canvas grew to ~460px and the diagram
* stayed where the narrow box had put it, its Start/End cards clipped at the
* left edge. The mount framing ran once and nothing re-ran it.
*
* The canvas is the real one. happy-dom lays nothing out, so the viewport's
* `clientWidth` / `clientHeight` are read from a box this file sets, and
* `ResizeObserver` is a double whose notifications the tests deliver, the way
* a browser delivers them: once after `observe`, then on each resize.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, act } from '@testing-library/react';
import { FlowCanvas } from './FlowCanvas';
import { NODE_W, PADDING, type FlowDesignerEdge, type FlowDesignerNode } from './flow-canvas-layout';

// The palette's engine overlay answers "engine absent" (its offline answer).
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('not found', { status: 404 })),
);

/** The diagram's width at 100% for a linear flow: one node column plus the layout's padding. */
const DIAGRAM_W = NODE_W + 2 * PADDING;

const box = { w: 0, h: 0 };
const observers = new Set<{ cb: ResizeObserverCallback; self: ResizeObserver }>();
class ResizeObserverDouble {
private entry: { cb: ResizeObserverCallback; self: ResizeObserver };
constructor(cb: ResizeObserverCallback) {
this.entry = { cb, self: this as unknown as ResizeObserver };
}
observe() {
observers.add(this.entry);
}
unobserve() {
observers.delete(this.entry);
}
disconnect() {
observers.delete(this.entry);
}
}
/** Deliver a notification to every live observer, as a browser does after a resize. */
function notify() {
act(() => {
for (const o of [...observers]) o.cb([], o.self);
});
}
function resizeTo(w: number, h: number) {
box.w = w;
box.h = h;
notify();
}

/** The prototype that owns `name` on the viewport element. */
function ownerOf(name: 'clientWidth' | 'clientHeight'): object {
let p: object | null = HTMLDivElement.prototype;
while (p && !Object.getOwnPropertyDescriptor(p, name)) p = Object.getPrototypeOf(p);
if (!p) throw new Error(`no prototype defines ${name}`);
return p;
}

let realResizeObserver: typeof ResizeObserver | undefined;
beforeEach(() => {
box.w = 0;
box.h = 0;
observers.clear();
realResizeObserver = globalThis.ResizeObserver;
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverDouble;
const isViewport = (el: Element) => el.getAttribute('role') === 'application';
vi.spyOn(ownerOf('clientWidth') as HTMLElement, 'clientWidth', 'get').mockImplementation(function (this: Element) {
return isViewport(this) ? box.w : 0;
});
vi.spyOn(ownerOf('clientHeight') as HTMLElement, 'clientHeight', 'get').mockImplementation(function (this: Element) {
return isViewport(this) ? box.h : 0;
});
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = realResizeObserver;
localStorage.clear();
});

type Draft = { nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] };

const flow = (): Draft => ({
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'assign', type: 'assignment', label: 'Set owner' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'assign' },
{ id: 'e2', source: 'assign', target: 'end' },
],
});

/** Mount the canvas in a host that keeps the draft, as Studio does; `edit` changes it from outside. */
function mountAt(w: number, h: number) {
box.w = w;
box.h = h;
const host: { edit: (fn: (d: Draft) => Draft) => void } = { edit: () => {} };
function Host() {
const [draft, setDraft] = React.useState<Draft>(flow);
host.edit = (fn) => act(() => setDraft(fn));
return (
<FlowCanvas
nodes={draft.nodes}
edges={draft.edges}
editable
designMode
selectedId={null}
onSelect={() => {}}
onPatch={(patch) => setDraft((d) => ({ ...d, ...(patch as Partial<Draft>) }))}
/>
);
}
render(<Host />);
// The browser's first notification after `observe`, at the width just framed.
notify();
return host;
}

/** The pan and zoom the canvas applied to the diagram, read off its transform. */
function view(): { x: number; y: number; zoom: number } {
const layer = screen.getByRole('application').firstElementChild as HTMLElement;
const m = /translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/.exec(layer.style.transform);
expect(m, `diagram transform: ${layer.style.transform}`).not.toBeNull();
return { x: Number(m![1]), y: Number(m![2]), zoom: Number(m![3]) };
}

describe('FlowCanvas — a width change re-fits the diagram (objectui#11795)', () => {
it('opened narrow and then widened, the diagram is framed for the new width, not left where the narrow box put it', () => {
mountAt(34, 541);
// The mount framing at 34px: centred at 100%, so the diagram's left edge is off-canvas.
expect(view().x).toBeLessThan(0);

resizeTo(460, 570);

const v = view();
expect(v.zoom).toBe(1);
expect(v.x).toBe((460 - DIAGRAM_W) / 2);
expect(v.x).toBeGreaterThanOrEqual(0);
expect(v.x + DIAGRAM_W * v.zoom).toBeLessThanOrEqual(460);
});

it('narrowed below the diagram, it zooms out until the whole flow fits, centred', () => {
mountAt(800, 600);
expect(view().zoom).toBe(1);

resizeTo(200, 600);

const v = view();
expect(v.zoom).toBeLessThan(1);
expect(v.x).toBeGreaterThanOrEqual(0);
expect(v.x + DIAGRAM_W * v.zoom).toBeLessThanOrEqual(200);
expect(screen.getByText(`${Math.round(v.zoom * 100)}%`)).toBeTruthy();
});

it('a wider canvas does not zoom a small flow past the 100% it opens at', () => {
mountAt(460, 570);

resizeTo(1400, 900);

expect(view()).toMatchObject({ zoom: 1, x: (1400 - DIAGRAM_W) / 2 });
});
});

describe('FlowCanvas — what an author does to the diagram never moves the viewport (objectui#11795, the control)', () => {
it('a node add re-arms the observer at the width already framed, and the view stays put', () => {
const host = mountAt(800, 600);
const before = view();

host.edit((d) => ({
nodes: [...d.nodes, { id: 'extra', type: 'assignment', label: 'Extra' }],
edges: [...d.edges, { id: 'e3', source: 'end', target: 'extra' }],
}));
// The re-armed observer's first notification arrives at the same width.
notify();

expect(document.querySelector('[data-node-id="extra"]')).not.toBeNull();
expect(view()).toEqual(before);
});

it('a height-only change — a notice landing above the canvas after an edit — does not re-fit', () => {
mountAt(800, 600);
const before = view();

resizeTo(800, 560);

expect(view()).toEqual(before);
});

it('a hidden canvas (0 wide) is not fitted into nothing', () => {
mountAt(800, 600);
const before = view();

resizeTo(0, 0);

expect(view()).toEqual(before);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ export function FlowCanvas({
const nodes = React.useMemo(() => withCanonicalGeometry(storedNodes), [storedNodes]);

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

// objectui#11795 — re-fit when the canvas's own WIDTH changes: a window
// resize, the Studio chat dock opening or closing, a side panel toggled
// beside the canvas. Without it the mount framing outlived its width: opened
// narrow and then widened, the diagram stayed where the narrow box put it,
// its cards clipped at the left edge. The fit is the toolbar's, capped at
// 100% so a small flow is not zoomed up past the scale it opens at.
//
// Width only, never height, which keeps the promise above: the size changes
// an edit causes are vertical — a held-edit or refused-save notice landing
// above the canvas, a Problems list growing under it — and must not yank the
// viewport from under the author. A node add, drag or edit changes the
// diagram, not the canvas, and re-arms this observer at the width it already
// framed, so it moves nothing. A 0 width is a hidden canvas: nothing to fit.
React.useEffect(() => {
const vp = viewportRef.current;
if (!vp || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
const width = vp.clientWidth;
if (width === 0 || width === framedWidthRef.current) return;
framedWidthRef.current = width;
const pad = 32;
// 1 is under MAX_ZOOM, so only the MIN_ZOOM floor of `clampZoom` can bind.
const z = Math.max(MIN_ZOOM, Math.min(1, (width - pad) / size.width, (vp.clientHeight - pad) / size.height));
setZoom(z);
setPan({
x: (width - size.width * z) / 2,
y: Math.max(16, (vp.clientHeight - size.height * z) / 2),
});
});
observer.observe(vp);
return () => observer.disconnect();
}, [size.height, size.width]);

// Pan to center an element when the Problems panel asks to reveal it. Driven
// by a changing `nonce` so re-clicking the same problem re-centers it.
React.useEffect(() => {
Expand Down
Loading
Loading