Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ interface Props {
step?: number;
min?: number;
max?: number;
/** Applied to the underlying input, for form semantics and testing. */
name?: string;
disabled?: boolean;
}

export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max }: Props) => {
export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max, name, disabled }: Props) => {
const inputRef = useRef<HTMLInputElement>(null);

// Store latest values in refs so the wheel handler always has current values
Expand All @@ -45,20 +48,23 @@ export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max }:
const minRef = useRef(min);
const maxRef = useRef(max);
const onChangeRef = useRef(onChange);
const disabledRef = useRef(disabled);

// Keep refs in sync
valueRef.current = value;
stepRef.current = step;
minRef.current = min;
maxRef.current = max;
onChangeRef.current = onChange;
disabledRef.current = disabled;

// Use non-passive wheel listener to allow preventDefault
useEffect(() => {
const input = inputRef.current;
if (!input) return;

const handleWheel = (e: WheelEvent) => {
if (disabledRef.current) return;
e.preventDefault();
const delta = e.deltaY < 0 ? stepRef.current : -stepRef.current;
let newValue = valueRef.current + delta;
Expand Down Expand Up @@ -91,11 +97,13 @@ export const PropertyRowNumberInput = ({ value, onChange, step = 1, min, max }:
variant="outlined"
value={value}
onChange={handleChange}
disabled={disabled}
slotProps={{
htmlInput: {
step,
min,
max,
name,
},
}}
sx={numberInputSx}
Expand Down
9 changes: 7 additions & 2 deletions src/components/workbench/WorkbenchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import {
// Import FlexLayout base styles + our overrides
import './workbenchTheme.css';

import { DEFAULT_LAYOUT, PANEL_IDS } from './defaultLayout';
import { DEFAULT_LAYOUT, PANEL_IDS, ensureSketchTab } from './defaultLayout';
import { CanvasPanel } from './panels/CanvasPanel';
import { ObjectsPanel } from './panels/ObjectsPanel';
import { SolversPanel } from './panels/SolversPanel';
import { RendererPanel } from './panels/RendererPanel';
import { SketchPanel } from './panels/SketchPanel';
import { ResultsPanelWrapper } from './panels/ResultsPanelWrapper';

import storage from '../../lib/storage';
Expand All @@ -38,7 +39,9 @@ function loadLayout(): IJsonModel {
try {
const stored = storage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
// Stored layouts are restored verbatim, so panels added after a user's
// layout was saved have to be patched in.
return ensureSketchTab(JSON.parse(stored));
}
} catch (e) {
console.warn('[WorkbenchLayout] Failed to parse stored layout:', e);
Expand Down Expand Up @@ -69,6 +72,8 @@ export function WorkbenchLayout() {
return <SolversPanel />;
case 'RendererPanel':
return <RendererPanel />;
case 'SketchPanel':
return <SketchPanel />;
case 'ResultsPanel':
return <ResultsPanelWrapper />;
default:
Expand Down
122 changes: 122 additions & 0 deletions src/components/workbench/__tests__/defaultLayout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Layout migration tests.
*
* Persisted layouts are restored verbatim and never consult DEFAULT_LAYOUT, so
* a panel added after a user's layout was saved is invisible to them until the
* stored model is patched.
*/

import { describe, it, expect } from 'vitest';
import type { IJsonModel } from 'flexlayout-react';
import { DEFAULT_LAYOUT, PANEL_IDS, SKETCH_TAB, ensureSketchTab } from '../defaultLayout';

/** A layout of the shape saved before the Sketch panel existed. */
const legacyLayout = (): IJsonModel => ({
global: {},
borders: [
{
type: 'border',
location: 'right',
size: 320,
selected: 1,
children: [
{ type: 'tab', id: 'objects', name: 'Objects', component: 'ObjectsPanel' },
{ type: 'tab', id: 'solvers', name: 'Solvers', component: 'SolversPanel' },
{ type: 'tab', id: 'renderer', name: 'Renderer', component: 'RendererPanel' },
],
},
],
layout: {
type: 'row',
children: [
{
type: 'tabset',
id: 'main',
children: [{ type: 'tab', id: 'canvas', name: 'Canvas', component: 'CanvasPanel' }],
},
],
},
});

const rightBorder = (layout: IJsonModel) =>
(layout.borders ?? []).find((b) => b.location === 'right');

const tabIds = (layout: IJsonModel) =>
(rightBorder(layout)?.children ?? []).map((c) => (c as { id?: string }).id);

describe('ensureSketchTab', () => {
it('adds the Sketch tab to a layout saved before it existed', () => {
const migrated = ensureSketchTab(legacyLayout());
expect(tabIds(migrated)).toEqual(['objects', 'solvers', 'renderer', PANEL_IDS.SKETCH]);
});

it('gives the added tab the same shape as the default', () => {
const migrated = ensureSketchTab(legacyLayout());
const added = rightBorder(migrated)!.children!.at(-1);
expect(added).toEqual(SKETCH_TAB);
});

it('leaves the current tab selection alone', () => {
const migrated = ensureSketchTab(legacyLayout());
expect(rightBorder(migrated)!.selected).toBe(1);
});

it('is a no-op when the tab is already present', () => {
const already = ensureSketchTab(legacyLayout());
expect(ensureSketchTab(already)).toBe(already);
});

it('is a no-op on the default layout', () => {
expect(ensureSketchTab(DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT);
});

it('is idempotent across repeated loads', () => {
let layout = legacyLayout();
for (let i = 0; i < 3; i++) layout = ensureSketchTab(layout);
expect(tabIds(layout).filter((id) => id === PANEL_IDS.SKETCH)).toHaveLength(1);
});

it('does not mutate the stored layout', () => {
const original = legacyLayout();
const snapshot = JSON.stringify(original);
ensureSketchTab(original);
expect(JSON.stringify(original)).toBe(snapshot);
});

it('finds the tab even when it sits in the main layout rather than a border', () => {
const layout = legacyLayout();
(layout.layout.children[0] as { children: unknown[] }).children.push({ ...SKETCH_TAB });
expect(ensureSketchTab(layout)).toBe(layout);
});

it('creates a right border when the saved layout has none', () => {
const layout = { ...legacyLayout(), borders: [] };
const migrated = ensureSketchTab(layout);
expect(tabIds(migrated)).toEqual([PANEL_IDS.SKETCH]);
});

it('leaves a newly created border closed rather than forcing it open', () => {
const migrated = ensureSketchTab({ ...legacyLayout(), borders: [] });
expect(rightBorder(migrated)!.selected).toBe(-1);
});

it('tolerates a layout with no borders key at all', () => {
const layout = legacyLayout();
delete layout.borders;
expect(tabIds(ensureSketchTab(layout))).toEqual([PANEL_IDS.SKETCH]);
});

it('does not disturb borders in other locations', () => {
const layout = legacyLayout();
layout.borders!.push({
type: 'border',
location: 'bottom',
children: [{ type: 'tab', id: 'results', name: 'Results', component: 'ResultsPanel' }],
});

const migrated = ensureSketchTab(layout);

const bottom = migrated.borders!.find((b) => b.location === 'bottom');
expect(bottom!.children!.map((c) => (c as { id?: string }).id)).toEqual(['results']);
});
});
3 changes: 3 additions & 0 deletions src/components/workbench/__tests__/flexlayout-smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ vi.mock('../panels/RendererPanel', () => ({ RendererPanel: () => <div>renderer-p
vi.mock('../panels/ResultsPanelWrapper', () => ({
ResultsPanelWrapper: () => <div>results-panel</div>,
}));
// Mocked like the rest — unmocked it reaches Surface and therefore compute/csg,
// whose jscad bundle loads from an http: URL and cannot resolve under vitest.
vi.mock('../panels/SketchPanel', () => ({ SketchPanel: () => <div>sketch-panel</div> }));

describe('flexlayout 0.10.0 smoke', () => {
beforeEach(() => localStorage.clear());
Expand Down
71 changes: 70 additions & 1 deletion src/components/workbench/defaultLayout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IJsonModel } from 'flexlayout-react';
import type { IJsonBorderNode, IJsonModel, IJsonTabNode } from 'flexlayout-react';

/**
* Default workbench layout configuration for CRAM
Expand Down Expand Up @@ -60,6 +60,14 @@ export const DEFAULT_LAYOUT: IJsonModel = {
enableClose: false,
enablePopout: false,
},
{
type: 'tab',
id: 'sketch',
name: 'Sketch',
component: 'SketchPanel',
Comment thread
rdmiller marked this conversation as resolved.
enableClose: false,
enablePopout: false,
},
],
},
],
Expand Down Expand Up @@ -118,5 +126,66 @@ export const PANEL_IDS = {
OBJECTS: 'objects',
SOLVERS: 'solvers',
RENDERER: 'renderer',
SKETCH: 'sketch',
RESULTS: 'results',
} as const;

/** The Sketch tab, also used to patch layouts saved before it existed. */
export const SKETCH_TAB: IJsonTabNode = {
type: 'tab',
id: PANEL_IDS.SKETCH,
name: 'Sketch',
component: 'SketchPanel',
enableClose: false,
enablePopout: false,
};

/** Depth-first search for a node id, over whatever shape the JSON happens to be. */
function containsNode(node: unknown, id: string): boolean {
if (!node || typeof node !== 'object') return false;
const candidate = node as { id?: unknown; children?: unknown };
if (candidate.id === id) return true;
return (
Array.isArray(candidate.children) && candidate.children.some((child) => containsNode(child, id))
);
}

/**
* Add the Sketch tab to a layout saved before it existed.
*
* Persisted layouts are restored verbatim and never consult DEFAULT_LAYOUT, so
* without this every existing user would be missing the panel with no way to
* reach it short of resetting their layout.
*
* Appends to the right border alongside Objects/Solvers/Renderer, leaving the
* user's current tab selection alone. Returns the input unchanged when the tab
* is already present, so it is safe to run on every load.
*/
export function ensureSketchTab(layout: IJsonModel): IJsonModel {
const borders = layout.borders ?? [];
const present =
containsNode(layout.layout, PANEL_IDS.SKETCH) ||
borders.some((border) => containsNode(border, PANEL_IDS.SKETCH));
if (present) return layout;

const tab: IJsonTabNode = { ...SKETCH_TAB };
const rightIndex = borders.findIndex((border) => border.location === 'right');

if (rightIndex === -1) {
const border = {
type: 'border',
location: 'right',
size: 320,
// Left closed: surfacing the tab is enough, forcing a panel open on
// upgrade would be intrusive.
selected: -1,
children: [tab],
} as IJsonBorderNode;
return { ...layout, borders: [...borders, border] };
}

const right = borders[rightIndex];
const nextBorders = borders.slice();
nextBorders[rightIndex] = { ...right, children: [...(right.children ?? []), tab] };
return { ...layout, borders: nextBorders };
}
Loading