From 846a8b70b3da9d61d6193e547d3e3853452ef33c Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 12 Oct 2026 14:44:28 +0200 Subject: [PATCH] Let ?w=&h= fix the editor canvas to a set size A recording of the editor is as large as its canvas, and so far the canvas always followed the window. With ?w=1920&h=1080 the editor is laid out at exactly that size in CSS pixels, whatever the window. - src/ui/pageSize.ts reads w and h (both needed, 200 to 8192 each) and works out how the canvas fits the window. - A smaller window shows the canvas shrunk to fit, centred, with the same aspect. A larger one shows it 1:1, centred on a dark page. - Clicks still land right, since Pixi maps them over the canvas's on-screen box. Switching projects keeps w and h in the URL. - The pointer drawn into an editor recording now scales against the layout size, so a shrunk canvas still records a full-size pointer. Tests: - Unit page-size: 5 new, green. - e2e 62d: ?w=1600&h=900 in a 960x720 window gives a 1600x900 canvas shown at 960x540, y 90, and a click on the menu button opens it. Red without the shrinking. - Gate on melektaus-atx: 304 files, 4691 tests green. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/editor/EditorRecorder.ts | 8 ++++--- src/page.ts | 21 +++++++++++++++++-- src/ui/pageSize.ts | 32 ++++++++++++++++++++++++++++ tests/ui/page-size.test.ts | 34 ++++++++++++++++++++++++++++++ tools/e2e/62-device-pixels.mjs | 38 ++++++++++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 src/ui/pageSize.ts create mode 100644 tests/ui/page-size.test.ts diff --git a/README.md b/README.md index 8b3238ee..a2d591c9 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,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, 2039 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2040 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/EditorRecorder.ts b/src/editor/EditorRecorder.ts index 23d28114..f04ad214 100644 --- a/src/editor/EditorRecorder.ts +++ b/src/editor/EditorRecorder.ts @@ -207,7 +207,8 @@ class CanvasSource implements FrameSource { const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size); const x = at.x + ((pointer.x - rect.left) / rect.width) * at.w; const y = at.y + ((pointer.y - rect.top) / rect.height) * at.h; - const scale = at.w / rect.width; + // Against the layout size: a canvas shrunk to fit (`?w=&h=`) still records a full-size pointer. + const scale = at.w / ui().app.screen.width; let path = this.paths.get(shape); if (!path) this.paths.set(shape, (path = new Path2D(shape.path))); const pen = this.framePen; @@ -263,8 +264,9 @@ class PointerTrack { this.attached = true; const rect = canvas.getBoundingClientRect(); const at = ui().app.renderer.events.pointer.global; - this.x = rect.left + at.x; - this.y = rect.top + at.y; + const shown = rect.width / ui().app.screen.width; + this.x = rect.left + at.x * shown; + this.y = rect.top + at.y * shown; this.inside = at.x > 0 || at.y > 0; window.addEventListener('pointermove', this.moved, { capture: true, passive: true }); window.addEventListener('pointerdown', this.moved, { capture: true, passive: true }); diff --git a/src/page.ts b/src/page.ts index 5dcb484f..ee163eb1 100644 --- a/src/page.ts +++ b/src/page.ts @@ -1,5 +1,6 @@ import { Application, Container } from 'pixi.js'; import { initPixelGrid, initRenderLoop, initUI, loadEditorFonts, Theme, tieBreakFor, VStack } from './ui'; +import { fixedPageSize, type PageSize, placePage } from './ui/pageSize'; // `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may // tear; `?vsync` opts out. Pixi has no option for it, hence our own context. @@ -17,13 +18,14 @@ function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2Rend /** * The one canvas a page is: the editor's and a released game's alike (#266). `root` fills the - * window and follows it when it is resized. + * window and follows it when it is resized, unless `?w=&h=` give it a fixed size. */ export async function createPage(params: URLSearchParams): Promise<{ app: Application; root: VStack }> { const fonts = loadEditorFonts(); + const fixed = fixedPageSize(params); const app = new Application(); await app.init({ - resizeTo: window, + ...(fixed ? { width: fixed.w, height: fixed.h } : { resizeTo: window }), background: Theme.colors.background, antialias: true, resolution: Math.min(2, window.devicePixelRatio || 1), @@ -33,6 +35,7 @@ export async function createPage(params: URLSearchParams): Promise<{ app: Applic }); initPixelGrid(app.renderer.resolution); document.body.appendChild(app.canvas); + if (fixed) pinCanvas(app.canvas, fixed); await fonts; const root = new VStack(); @@ -52,6 +55,20 @@ export async function createPage(params: URLSearchParams): Promise<{ app: Applic return { app, root }; } +/** Keeps a canvas of a fixed size (`?w=&h=`) in the window: scaled down if it does not fit, centred. */ +function pinCanvas(canvas: HTMLCanvasElement, size: PageSize): void { + const place = (): void => { + const at = placePage(size, { w: window.innerWidth, h: window.innerHeight }); + canvas.style.position = 'absolute'; + canvas.style.left = `${at.x}px`; + canvas.style.top = `${at.y}px`; + canvas.style.transformOrigin = '0 0'; + canvas.style.transform = at.scale < 1 ? `scale(${at.scale})` : ''; + }; + place(); + window.addEventListener('resize', place); +} + export function showFailure(error: unknown): void { console.error(error); document.body.innerHTML = `
${String(error)}
`; diff --git a/src/ui/pageSize.ts b/src/ui/pageSize.ts new file mode 100644 index 00000000..35ae08e8 --- /dev/null +++ b/src/ui/pageSize.ts @@ -0,0 +1,32 @@ +export interface PageSize { + w: number; + h: number; +} + +const MIN_SIDE = 200; +const MAX_SIDE = 8192; + +/** + * `?w=1920&h=1080`: the page's canvas keeps this size in CSS pixels instead of following the window. + * Both are needed. A side outside 200…8192 is clamped. + */ +export function fixedPageSize(params: URLSearchParams): PageSize | null { + const w = side(params.get('w')); + const h = side(params.get('h')); + return w && h ? { w, h } : null; +} + +function side(raw: string | null): number | null { + if (raw === null || !/^\d+$/.test(raw.trim())) return null; + return Math.min(MAX_SIDE, Math.max(MIN_SIDE, Number(raw))); +} + +/** Where a fixed-size canvas sits in the window: shrunk to fit a smaller one, never enlarged, centred. */ +export function placePage(size: PageSize, window: PageSize): { x: number; y: number; scale: number } { + const scale = Math.min(1, window.w / size.w, window.h / size.h); + return { + x: Math.round((window.w - size.w * scale) / 2), + y: Math.round((window.h - size.h * scale) / 2), + scale, + }; +} diff --git a/tests/ui/page-size.test.ts b/tests/ui/page-size.test.ts new file mode 100644 index 00000000..78b368ae --- /dev/null +++ b/tests/ui/page-size.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { fixedPageSize, placePage } from '../../src/ui/pageSize'; + +const size = (search: string) => fixedPageSize(new URLSearchParams(search)); + +describe('fixedPageSize', () => { + it('reads ?w=&h=', () => { + expect(size('?w=1920&h=1080')).toEqual({ w: 1920, h: 1080 }); + expect(size('?project=abc&h=720&w=1280')).toEqual({ w: 1280, h: 720 }); + }); + + it('needs both sides as whole numbers', () => { + expect(size('')).toBeNull(); + expect(size('?w=1920')).toBeNull(); + expect(size('?w=1920&h=')).toBeNull(); + expect(size('?w=1920&h=10.5')).toBeNull(); + expect(size('?w=-5&h=1080')).toBeNull(); + expect(size('?w=abc&h=1080')).toBeNull(); + }); + + it('clamps a side to 200…8192', () => { + expect(size('?w=50&h=100000')).toEqual({ w: 200, h: 8192 }); + }); +}); + +describe('placePage', () => { + it('centres a canvas smaller than the window at full size', () => { + expect(placePage({ w: 1920, h: 1080 }, { w: 2560, h: 1440 })).toEqual({ x: 320, y: 180, scale: 1 }); + }); + + it('shrinks a canvas larger than the window to fit, keeping its aspect', () => { + expect(placePage({ w: 1920, h: 1080 }, { w: 960, h: 900 })).toEqual({ x: 0, y: 180, scale: 0.5 }); + }); +}); diff --git a/tools/e2e/62-device-pixels.mjs b/tools/e2e/62-device-pixels.mjs index a9ef510f..f79f7f26 100644 --- a/tools/e2e/62-device-pixels.mjs +++ b/tools/e2e/62-device-pixels.mjs @@ -94,6 +94,44 @@ export async function devicePixels(t) { ); await t.shot('62-device-pixels'); + // 62d. `?w=&h=` give the canvas a size of its own: the editor is laid out at that size, a window + // too small for it shows it shrunk to fit and centred, and a click lands where the picture is. + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: 960, + height: 720, + deviceScaleFactor: 1, + mobile: false, + }); + await cdp.send('Page.navigate', { url: `${t.baseUrl}?project=${t.projectId}&w=1600&h=900` }); + await t.sleep(4000); + await t.waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(t.projectId)}`, { + timeout: 15000, + label: 'the editor at a fixed size is up', + }); + const fixed = await json(`(() => { + const canvas = document.querySelector('canvas'); + const r = canvas.getBoundingClientRect(); + const round = (v) => Math.round(v * 100) / 100; + return { w: canvas.width, h: canvas.height, shown: { x: round(r.x), y: round(r.y), w: round(r.width), h: round(r.height) } }; + })()`); + const menu = await json('__gm.toolbarAt("menu")'); + const factor = fixed.shown.w / fixed.w; + await t.click(fixed.shown.x + menu.x * factor, fixed.shown.y + menu.y * factor); + const rows = await json('__gm.menuRows()'); + await t.press('Escape'); + check( + '?w=1600&h=900 lays the editor out at 1600×900, shrinks it into a 960×720 window and still takes clicks', + fixed.w === 1600 && + fixed.h === 900 && + fixed.shown.x === 0 && + fixed.shown.y === 90 && + fixed.shown.w === 960 && + fixed.shown.h === 540 && + Array.isArray(rows) && + rows.includes('Start recording'), + JSON.stringify({ fixed, menu, rows }), + ); + await scale(1); await navigate(); const back = await json('__gm.pixelGrid()');