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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 14:44:28 +02:00
1 parent d6a325978c
commit 846a8b70b3
6 files changed
+129 -6

No files matched your search

+1 -1
View File
@@ -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
+5 -3
View File
@@ -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 });
+19 -2
View File
@@ -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 = `<pre style="color:#e0553f;padding:16px;font:13px monospace">${String(error)}</pre>`;
+32
View File
@@ -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,
};
}
+34
View File
@@ -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 });
});
});
+38
View File
@@ -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()');