Keep where each divider was dragged to across a reload
A panel dragged wider was back at its first size after every reload. The sizes are now kept in the browser, next to the folded groups. - editor/panelSizes.ts holds one entry in localStorage, game-maker.panel-sizes: a name per divider and its width, height or share. - A panel reads its size once when it is built, held to the bounds its divider keeps today. Every drag writes the new size. - Kept are the columns left and right of the centre, the console, the split of the left column, the tilemap panel, the preview of the Shader, Particles and Pixel art tabs, each under its own name, and the split of the user management. - The sizes belong to the browser, not to the project. They hold for every project opened there. - A browser that refuses the storage, or holds rubbish under the entry, starts with the sizes the editor comes with. Tests: - Gate on melektaus-atx: 219 files, 3400 tests, CHECK EXIT 0. - Seven new unit tests in tests/editor/panel-sizes.test.ts. All five counter-probes bite. - Smokes on melektaus-atx: 24, 19 and 21, all pass. - One new e2e check in module 48: the left column dragged 40 px wider is as wide after a reload, and dragged back after. - Full suite on melektaus-atx: 1692 checks, all pass. It ran before the fix to the animation players of the commit before last. - Browser counter-probe: without the write to the browser exactly that check fails. - Only the left column is measured across a reload. The other dividers go the same way and have no check of their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8f9d373d7b
commit
ca5b2db449
10 files changed
+201
-12
No files matched your search
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1691 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1692 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
+14
-5
@@ -52,6 +52,7 @@ import { Inspector } from './inspector/Inspector';
|
||||
import { valuesTitle, VariablesEditor } from './variables/VariablesEditor';
|
||||
import { valuesKey } from './variables/target';
|
||||
import { pickView } from './objectPick';
|
||||
import { keepPanelSize, panelSize } from './panelSizes';
|
||||
import { AnimationEditor } from './animation/AnimationEditor';
|
||||
import { TileEditor } from './tile/TileEditor';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
@@ -188,6 +189,8 @@ const MIN_CONSOLE_HEIGHT = Panel.HEADER_HEIGHT;
|
||||
const MAX_CONSOLE_HEIGHT = 600;
|
||||
// Either panel of the left column keeps at least its header, for the same reason as the console.
|
||||
const MIN_PANEL_HEIGHT = Panel.HEADER_HEIGHT;
|
||||
// What a share read back from the browser is held to before the column has a height to measure by.
|
||||
const MIN_KEPT_SHARE = 0.03;
|
||||
|
||||
/**
|
||||
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
|
||||
@@ -260,13 +263,14 @@ export class Editor implements PlayHost {
|
||||
private readonly cleanups: (() => void)[] = [];
|
||||
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
|
||||
private disposed = false;
|
||||
private leftWidth = 250;
|
||||
private rightWidth = 350;
|
||||
// Each starts where its divider was left in this browser (`panelSizes.ts`), else at the number here.
|
||||
private leftWidth = panelSize('left', 250, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
|
||||
private rightWidth = panelSize('right', 350, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||||
/** Starts out as wide as the inspector, then goes its own way. */
|
||||
private tilemapWidth = this.rightWidth;
|
||||
private consoleHeight = 300;
|
||||
private tilemapWidth = panelSize('tilemap', this.rightWidth, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||||
private consoleHeight = panelSize('console', 300, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
|
||||
/** The asset panel's part of the left column's height; the scene objects get the rest. */
|
||||
private assetsShare = 0.5;
|
||||
private assetsShare = panelSize('assets-share', 0.5, MIN_KEPT_SHARE, 1 - MIN_KEPT_SHARE);
|
||||
|
||||
constructor(
|
||||
app: Application,
|
||||
@@ -366,6 +370,7 @@ export class Editor implements PlayHost {
|
||||
this.assets.flex = this.assetsShare;
|
||||
this.hierarchy.flex = 1 - this.assetsShare;
|
||||
this.leftColumn.relayout();
|
||||
keepPanelSize('assets-share', this.assetsShare);
|
||||
};
|
||||
|
||||
this.centre = new VStack();
|
||||
@@ -384,6 +389,7 @@ export class Editor implements PlayHost {
|
||||
this.tilemapWidth = clamp(this.tilemapWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||||
this.tilemapPanel.minWidth = this.tilemapWidth;
|
||||
this.sceneRow.relayout();
|
||||
keepPanelSize('tilemap', this.tilemapWidth);
|
||||
};
|
||||
this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel();
|
||||
this.syncTilemapPanel();
|
||||
@@ -431,6 +437,7 @@ export class Editor implements PlayHost {
|
||||
this.consoleHeight = clamp(this.consoleHeight - delta, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
|
||||
this.console.minHeight = this.consoleHeight;
|
||||
this.centre.relayout();
|
||||
keepPanelSize('console', this.consoleHeight);
|
||||
};
|
||||
|
||||
this.leftColumn.minWidth = this.leftWidth;
|
||||
@@ -439,11 +446,13 @@ export class Editor implements PlayHost {
|
||||
this.leftWidth = clamp(this.leftWidth + delta, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
|
||||
this.leftColumn.minWidth = this.leftWidth;
|
||||
this.workspace.relayout();
|
||||
keepPanelSize('left', this.leftWidth);
|
||||
};
|
||||
this.rightSplitter.onDrag = (delta) => {
|
||||
this.rightWidth = clamp(this.rightWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||||
this.inspector.minWidth = this.rightWidth;
|
||||
this.workspace.relayout();
|
||||
keepPanelSize('right', this.rightWidth);
|
||||
};
|
||||
this.workspace.add(this.leftColumn);
|
||||
this.workspace.add(this.leftSplitter);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clamp, HStack, Splitter, type Widget } from '../ui';
|
||||
import { keepPanelSize, panelSize } from './panelSizes';
|
||||
|
||||
/** The panel can be dragged in to the width of its own controls, and open to most of a wide window. */
|
||||
const MIN_PREVIEW = 200;
|
||||
@@ -11,16 +12,20 @@ const MAX_PREVIEW = 640;
|
||||
*/
|
||||
export class PreviewSplit extends HStack {
|
||||
readonly splitter = new Splitter('x');
|
||||
private panelWidth = 280;
|
||||
private panelWidth: number;
|
||||
|
||||
constructor(canvas: Widget, side: Widget) {
|
||||
/** `name` is the tab this stands in: each keeps the width of its own panel in the browser. */
|
||||
constructor(canvas: Widget, side: Widget, name: string) {
|
||||
super();
|
||||
const kept = `preview:${name}`;
|
||||
this.panelWidth = panelSize(kept, 280, MIN_PREVIEW, MAX_PREVIEW);
|
||||
side.minWidth = this.panelWidth;
|
||||
this.splitter.onDrag = (delta) => {
|
||||
this.panelWidth = clamp(this.panelWidth - delta, MIN_PREVIEW, MAX_PREVIEW);
|
||||
side.minWidth = this.panelWidth;
|
||||
// The row keeps its own size, so resizing it would not lay out again: ask it directly.
|
||||
this.relayout();
|
||||
keepPanelSize(kept, this.panelWidth);
|
||||
};
|
||||
this.add(canvas, 1);
|
||||
this.add(this.splitter);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* How far each divider was dragged, kept in this browser: a panel made wider stays wider after a
|
||||
* reload, in every project. Best effort, like the folds – a browser that refuses `localStorage`
|
||||
* forgets with the page.
|
||||
*/
|
||||
import { clamp } from '../ui/math';
|
||||
|
||||
const STORAGE_KEY = 'game-maker.panel-sizes';
|
||||
|
||||
let sizes: Map<string, number> | null = null;
|
||||
|
||||
function storage(): Pick<Storage, 'getItem' | 'setItem'> | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function read(): Map<string, number> {
|
||||
try {
|
||||
const stored: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? '{}');
|
||||
if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) return new Map();
|
||||
return new Map(
|
||||
Object.entries(stored).filter((entry): entry is [string, number] => Number.isFinite(entry[1])),
|
||||
);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The size a panel starts with: what was kept under `name`, held to the bounds its divider keeps
|
||||
* today, or `fallback` where nothing was kept.
|
||||
*/
|
||||
export function panelSize(name: string, fallback: number, min: number, max: number): number {
|
||||
sizes ??= read();
|
||||
const kept = sizes.get(name);
|
||||
return kept === undefined ? fallback : clamp(kept, min, max);
|
||||
}
|
||||
|
||||
export function keepPanelSize(name: string, size: number): void {
|
||||
sizes ??= read();
|
||||
sizes.set(name, size);
|
||||
try {
|
||||
storage()?.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries([...sizes].sort())));
|
||||
} catch {
|
||||
// Kept for this page only.
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets what was read, so the next question asks the browser again (tests). */
|
||||
export function resetPanelSizes(): void {
|
||||
sizes = null;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
||||
const side = new VStack();
|
||||
side.add(bar);
|
||||
side.add(this.preview, 1);
|
||||
const split = new PreviewSplit(canvas, side);
|
||||
const split = new PreviewSplit(canvas, side, 'particles');
|
||||
this.splitter = split.splitter;
|
||||
return split;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export class PixelArtEditor extends AssetTabEditor<PixelArtAsset, PixelArtCanvas
|
||||
|
||||
protected override body(canvas: PixelArtCanvas): Widget {
|
||||
this.side = new PixelArtSide(this.state, canvas);
|
||||
const split = new PreviewSplit(canvas, this.side);
|
||||
const split = new PreviewSplit(canvas, this.side, 'pixelart');
|
||||
this.splitter = split.splitter;
|
||||
return split;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
this.side.add(bar);
|
||||
this.side.add(this.preview, 1);
|
||||
this.side.add(this.params);
|
||||
const split = new PreviewSplit(canvas, this.side);
|
||||
const split = new PreviewSplit(canvas, this.side, 'shader');
|
||||
this.splitter = split.splitter;
|
||||
return split;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clamp, Splitter, VStack } from '../../ui';
|
||||
import { keepPanelSize, panelSize } from '../panelSizes';
|
||||
import type { PluginsPane } from '../plugins/PluginsPane';
|
||||
import type { UsersPane } from './UsersPane';
|
||||
|
||||
@@ -10,7 +11,7 @@ const MIN_SHARE = 0.15;
|
||||
* place in the tab row for good. An account that is no administrator sees the plugins alone.
|
||||
*/
|
||||
export class ManagementPane extends VStack {
|
||||
private usersShare = 0.6;
|
||||
private usersShare = panelSize('users-share', 0.6, MIN_SHARE, 1 - MIN_SHARE);
|
||||
|
||||
constructor(
|
||||
readonly users: UsersPane | null,
|
||||
@@ -32,6 +33,7 @@ export class ManagementPane extends VStack {
|
||||
users.flex = this.usersShare;
|
||||
plugins.flex = 1 - this.usersShare;
|
||||
this.relayout();
|
||||
keepPanelSize('users-share', this.usersShare);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** The sizes the dividers were dragged to, kept in the browser: what is written, read back and refused. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { keepPanelSize, panelSize, resetPanelSizes } from '../../src/editor/panelSizes';
|
||||
|
||||
const STORAGE_KEY = 'game-maker.panel-sizes';
|
||||
|
||||
function memoryStore(initial: Record<string, string> = {}) {
|
||||
const items = new Map(Object.entries(initial));
|
||||
return {
|
||||
items,
|
||||
getItem: (key: string) => items.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void items.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe('the memory of panel sizes', () => {
|
||||
beforeEach(() => resetPanelSizes());
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetPanelSizes();
|
||||
});
|
||||
|
||||
it('starts a panel at its own size where nothing was kept', () => {
|
||||
vi.stubGlobal('window', { localStorage: memoryStore() });
|
||||
expect(panelSize('left', 250, 160, 500)).toBe(250);
|
||||
});
|
||||
|
||||
it('writes a size to the browser and reads it back on a new page', () => {
|
||||
const store = memoryStore();
|
||||
vi.stubGlobal('window', { localStorage: store });
|
||||
keepPanelSize('left', 320);
|
||||
expect(panelSize('left', 250, 160, 500)).toBe(320);
|
||||
resetPanelSizes();
|
||||
expect(panelSize('left', 250, 160, 500)).toBe(320);
|
||||
expect(JSON.parse(store.items.get(STORAGE_KEY) ?? '')).toEqual({ left: 320 });
|
||||
});
|
||||
|
||||
it('keeps every panel under its own name', () => {
|
||||
const store = memoryStore();
|
||||
vi.stubGlobal('window', { localStorage: store });
|
||||
keepPanelSize('right', 410);
|
||||
keepPanelSize('console', 120);
|
||||
keepPanelSize('right', 430);
|
||||
resetPanelSizes();
|
||||
expect(panelSize('right', 350, 200, 600)).toBe(430);
|
||||
expect(panelSize('console', 300, 28, 600)).toBe(120);
|
||||
expect(panelSize('left', 250, 160, 500)).toBe(250);
|
||||
expect(JSON.parse(store.items.get(STORAGE_KEY) ?? '')).toEqual({ console: 120, right: 430 });
|
||||
});
|
||||
|
||||
it('holds a kept size to the bounds the divider keeps today', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: memoryStore({ [STORAGE_KEY]: JSON.stringify({ left: 9000, right: 3, share: 0.5 }) }),
|
||||
});
|
||||
expect(panelSize('left', 250, 160, 500)).toBe(500);
|
||||
expect(panelSize('right', 350, 200, 600)).toBe(200);
|
||||
expect(panelSize('share', 0.6, 0.15, 0.85)).toBe(0.5);
|
||||
});
|
||||
|
||||
it('reads rubbish as nothing', () => {
|
||||
for (const rubbish of ['{broken', '[300]', '"wide"', 'null', '{"left":"300","right":null}']) {
|
||||
resetPanelSizes();
|
||||
vi.stubGlobal('window', { localStorage: memoryStore({ [STORAGE_KEY]: rubbish }) });
|
||||
expect(panelSize('left', 250, 160, 500), rubbish).toBe(250);
|
||||
expect(panelSize('right', 350, 200, 600), rubbish).toBe(350);
|
||||
}
|
||||
});
|
||||
|
||||
it('remembers for the page where the browser gives no storage', () => {
|
||||
keepPanelSize('left', 300);
|
||||
expect(panelSize('left', 250, 160, 500)).toBe(300);
|
||||
resetPanelSizes();
|
||||
expect(panelSize('left', 250, 160, 500)).toBe(250);
|
||||
});
|
||||
|
||||
it('remembers for the page where the browser refuses to write', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error('quota');
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => keepPanelSize('left', 300)).not.toThrow();
|
||||
expect(panelSize('left', 250, 160, 500)).toBe(300);
|
||||
});
|
||||
});
|
||||
@@ -730,8 +730,38 @@ export async function pixelArt(t) {
|
||||
pending === 0 && files.length >= 2 && statuses.every((s) => s === 200),
|
||||
JSON.stringify({ pending, files: files.length, statuses }),
|
||||
);
|
||||
// 48m-ii. A divider stays where it was left: its place is kept in the browser, not in the project,
|
||||
// so the reload brings the wider panel back. Dragged back after, for the modules to come.
|
||||
const keptSizes = () => json(`JSON.parse(localStorage.getItem('game-maker.panel-sizes') ?? '{}')`);
|
||||
const columnBefore = await json('__gm.leftColumn()');
|
||||
const bars = await json('__gm.panelSplitters()');
|
||||
await drag(bars.left.x, bars.left.y, bars.left.x + 40, bars.left.y, { steps: 6 });
|
||||
await settle();
|
||||
const columnWide = await json('__gm.leftColumn()');
|
||||
const sizesWide = await keptSizes();
|
||||
await navigate();
|
||||
await sleep(500);
|
||||
const columnKept = await json('__gm.leftColumn()');
|
||||
const barsKept = await json('__gm.panelSplitters()');
|
||||
await drag(barsKept.left.x, barsKept.left.y, barsKept.left.x - 40, barsKept.left.y, { steps: 6 });
|
||||
await settle();
|
||||
const columnBack = await json('__gm.leftColumn()');
|
||||
check(
|
||||
'a panel dragged wider is as wide after a reload, and the browser keeps its size',
|
||||
columnWide.assets.w === columnBefore.assets.w + 40 &&
|
||||
sizesWide.left === columnWide.assets.w &&
|
||||
columnKept.assets.w === columnWide.assets.w &&
|
||||
columnBack.assets.w === columnBefore.assets.w &&
|
||||
(await keptSizes()).left === columnBefore.assets.w,
|
||||
JSON.stringify({
|
||||
before: columnBefore.assets.w,
|
||||
wide: columnWide.assets.w,
|
||||
kept: columnKept.assets.w,
|
||||
back: columnBack.assets.w,
|
||||
sizesWide,
|
||||
sizes: await keptSizes(),
|
||||
}),
|
||||
);
|
||||
await evaluate(`__gm.openDocument('asset:' + ${JSON.stringify(id)})`);
|
||||
await settle();
|
||||
await sleep(500);
|
||||
|
||||
Reference in new issue
Block a user