diff --git a/README.md b/README.md index 10d922be..9f404592 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index ede4a590..bf51fa25 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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; 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); diff --git a/src/editor/PreviewSplit.ts b/src/editor/PreviewSplit.ts index 9c6c07a0..2a9343d8 100644 --- a/src/editor/PreviewSplit.ts +++ b/src/editor/PreviewSplit.ts @@ -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); diff --git a/src/editor/panelSizes.ts b/src/editor/panelSizes.ts new file mode 100644 index 00000000..7b73ab89 --- /dev/null +++ b/src/editor/panelSizes.ts @@ -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 | null = null; + +function storage(): Pick | null { + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } +} + +function read(): Map { + 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; +} diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts index 6a3d5e4a..5305a9d7 100644 --- a/src/editor/particles/ParticlesEditor.ts +++ b/src/editor/particles/ParticlesEditor.ts @@ -70,7 +70,7 @@ export class ParticlesEditor extends AssetTabEditor 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; } diff --git a/src/editor/pixelart/PixelArtEditor.ts b/src/editor/pixelart/PixelArtEditor.ts index 6b64340d..b1db916d 100644 --- a/src/editor/pixelart/PixelArtEditor.ts +++ b/src/editor/pixelart/PixelArtEditor.ts @@ -36,7 +36,7 @@ export class PixelArtEditor extends AssetTabEditor { 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; } diff --git a/src/editor/users/ManagementPane.ts b/src/editor/users/ManagementPane.ts index dade8e88..fb8b8d34 100644 --- a/src/editor/users/ManagementPane.ts +++ b/src/editor/users/ManagementPane.ts @@ -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); }; } } diff --git a/tests/editor/panel-sizes.test.ts b/tests/editor/panel-sizes.test.ts new file mode 100644 index 00000000..93b0080c --- /dev/null +++ b/tests/editor/panel-sizes.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index d9da7c35..55eced2c 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -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);