User management: signing in is required before anything is loaded, the sign-in screen is drawn in the same canvas (user name, password as bullets, Enter moves on and submits) and the editor is built only after it; the API gained auth/session, auth/login and auth/logout while every other route answers 401 without a session, projects carry an owner and a foreign one comes back as 404, accounts live in data/users.json with a password hash and sessions as files in data/sessions with a sliding 30 days, so a build under the open tab does not sign anybody out; Logout sits at the far right of the toolbar and, as asked in the issue, does not reload the page: Editor.dispose() takes the editor down while the canvas stays, which is why the autosave, the focus handlers, the window listeners of the tilemap panel, the shortcuts and the plugins all got a way back; the tools sign in as e2e (tools/lib/auth.mjs, password from GAME_MAKER_E2E_PASSWORD or the git-ignored data/e2e-password.txt) and the browser gets the same session by cookie, new e2e module 45 covers the screen, the wrong pair, the bullets, logout without a reload and signing in again, new unit tests for HttpAuthStore and for what a session leaves behind (#177)
game-maker / gate (push) Successful in 2m5s
game-maker / suite (push) Failing after 6m22s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 12:50:52 +02:00
1 parent 60ea56a2ce
commit 8e4bb6e1f9
38 files changed
+1530 -139

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
/**
* Signing in and out. The token itself never reaches this code: the server keeps the session and
* hands the browser a cookie, so all that travels here is the name behind it.
*/
export interface AuthStore {
/** The signed-in user, or null while nobody is. */
session(): Promise<string | null>;
/** The user name on success; a rejected promise when the pair is wrong. */
logIn(username: string, password: string): Promise<string>;
logOut(): Promise<void>;
}
/** Thrown for a wrong user name or password, as opposed to a server that is not there. */
export class WrongCredentialsError extends Error {
constructor(message = 'Wrong user name or password') {
super(message);
this.name = 'WrongCredentialsError';
}
}
+32
View File
@@ -0,0 +1,32 @@
import { type AuthStore, WrongCredentialsError } from './AuthStore';
import { apiRequest } from './http';
export class HttpAuthStore implements AuthStore {
constructor(private readonly endpoint: string) {}
async session(): Promise<string | null> {
const body = await apiRequest<{ user: string | null }>(this.endpoint, 'GET', 'auth/session');
return body.user;
}
async logIn(username: string, password: string): Promise<string> {
const body = await apiRequest<{ user: string }>(
this.endpoint,
'POST',
'auth/login',
{
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
},
// A wrong password is the expected answer of a login screen, not a breakdown: it gets its
// own type so the screen can say one thing about it and another about a server that is down.
(status, data) =>
status === 401 ? new WrongCredentialsError((data as { error?: string } | null)?.error) : undefined,
);
return body.user;
}
async logOut(): Promise<void> {
await apiRequest<void>(this.endpoint, 'POST', 'auth/logout');
}
}
+93 -16
View File
@@ -1,4 +1,6 @@
import type { Application } from 'pixi.js';
import type { AuthStore } from '../core/store/AuthStore';
import { HttpAuthStore } from '../core/store/HttpAuthStore';
import { HttpFileStore } from '../core/store/HttpFileStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
@@ -7,6 +9,9 @@ import { requestRender, type VStack } from '../ui';
import { fontLibrary } from '../viewport/FontLibrary';
import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import { LoginScreen } from './auth/LoginScreen';
import { installLoginHook } from './auth/loginHook';
import { consoleLog } from './console/log';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
import { startPlugins } from './plugins/startPlugins';
@@ -22,27 +27,99 @@ const TEXTURE_BASE = '/game-maker/data/textures';
const FONT_BASE = '/game-maker/data/fonts';
const SOUND_BASE = '/game-maker/data/sounds';
/** Wires state, stores and the editor composition together and opens the first project. */
/**
* Shows the sign-in screen until somebody is signed in, and answers with their name. Returns
* right away when the session from an earlier visit is still good – which is what lets a
* deployment replace the editor under an open tab without asking for the password again.
*/
async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise<string> {
const existing = await auth.session();
if (existing !== null) return existing;
let done: (user: string) => void = () => {};
const screen = new LoginScreen(auth, (user) => done(user));
const removeHook = installLoginHook(app, screen);
const user = await new Promise<string>((resolve) => {
done = resolve;
root.add(screen, 1);
root.resize(app.screen.width, app.screen.height);
screen.focusFirstField();
});
removeHook();
root.removeChild(screen);
// The editor sizes `root` itself once it is built, but `resize` lays out only when the size
// changed – and the sign-in screen has already set it to the size of the window. Forgetting the
// size here is what makes that call count again; without it the whole editor sits unlaid-out in
// the corner.
root.resize(0, 0);
screen.destroy({ children: true });
return user;
}
/**
* Wires state, stores and the editor composition together and opens the first project.
*
* Signing out does not reload the page (#177): it takes the editor down and puts the sign-in
* screen back, in the same canvas with the same renderer. So this is a loop rather than a straight
* line – every round is one account's session. Only the first round is awaited, because the caller
* has more to set up once the editor is up; the rounds after it run on their own.
*/
export async function startEditor(app: Application, root: VStack): Promise<void> {
const state = new EditorState();
const auth = new HttpAuthStore(API_ENDPOINT);
textureLibrary.configure(TEXTURE_BASE);
fontLibrary.configure(FONT_BASE);
soundLibrary.configure(SOUND_BASE);
const editor = new Editor(
app,
root,
state,
new HttpProjectStore(API_ENDPOINT),
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'),
);
// An image or a font arrives long after the click that asked for it, and the widget waiting for
// it repaints without telling anyone: both libraries are a wake-up of their own.
// it repaints without telling anyone: both libraries are a wake-up of their own. Registered once
// for the page, not per session – the files they hold are shared by every account anyway.
textureLibrary.onChange(() => requestRender());
fontLibrary.onChange(() => requestRender());
installDebugHook(app, state, editor);
await editor.boot();
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
await startPlugins(API_ENDPOINT, state, editor);
const openSession = async (): Promise<void> => {
const user = await signedInUser(app, root, auth);
// Who is signed in goes into the window title: the toolbar is crowded enough, and the title is
// also what tells two tabs on two accounts apart.
document.title = `Game Maker – ${user}`;
const state = new EditorState();
const editor = new Editor(
app,
root,
state,
new HttpProjectStore(API_ENDPOINT),
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
new HttpFileStore(API_ENDPOINT, 'fonts'),
new HttpFileStore(API_ENDPOINT, 'sounds'),
{
logOut: () =>
void endSession(editor).catch((error: unknown) => {
console.error('[auth] Signing out failed:', error);
}),
},
);
installDebugHook(app, state, editor);
await editor.boot();
loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error));
await startPlugins(API_ENDPOINT, state, editor);
};
/**
* Ends the session the Logout button belongs to: the editor writes what is unsaved and lets go
* of everything, then the server drops the session, and the sign-in screen comes back. The
* editor goes first, so its last save still travels with a session the server accepts.
*/
const endSession = async (editor: Editor): Promise<void> => {
await editor.dispose();
document.title = 'Game Maker';
// The console panel is page-wide, the lines in it are not: what one account's project printed
// is none of the next one's business.
consoleLog.clear();
try {
await auth.logOut();
} catch (error) {
console.error('[auth] Signing out did not reach the server:', error);
}
await openSession();
};
await openSession();
}
+54 -12
View File
@@ -1,4 +1,4 @@
import type { Application } from 'pixi.js';
import type { Application, Ticker } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
@@ -73,6 +73,11 @@ export type PluginTabId = `plugin:${string}`;
export type TabId = CentreTab | PluginTabId;
/** An open document (#171): an asset by its id, or the list the List tab edits – there is one. */
export type DocumentId = `asset:${string}` | 'list';
/** How to end the session; the button for it sits in the toolbar. */
export interface EditorAccount {
logOut: () => void;
}
/** A button in the tab row. */
export type StripTab = FixedTab | PluginTabId | DocumentId;
@@ -188,6 +193,10 @@ export class Editor implements PlayHost {
/** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0;
private fpsElapsed = 0;
/** What this editor registered outside itself, as the calls that take it away again (#177). */
private readonly cleanups: (() => void)[] = [];
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
private disposed = false;
private leftWidth = 250;
private rightWidth = 350;
/** Starts out as wide as the inspector, then goes its own way. */
@@ -198,12 +207,13 @@ export class Editor implements PlayHost {
constructor(
app: Application,
root: VStack,
private readonly root: VStack,
private readonly state: EditorState,
store: ProjectStore,
textures: TextureStore,
fonts: FontStore,
sounds: SoundStore,
account: EditorAccount,
) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
@@ -249,6 +259,7 @@ export class Editor implements PlayHost {
openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
logOut: () => account.logOut(),
});
root.add(this.toolbar);
@@ -256,7 +267,7 @@ export class Editor implements PlayHost {
this.leftColumn = new VStack();
this.assets = new AssetPanel(state, textures, fonts, sounds);
this.leftColumn.add(this.assets, this.assetsShare);
installFileImport(app.canvas, { state, textures, fonts, sounds });
this.cleanups.push(installFileImport(app.canvas, { state, textures, fonts, sounds }));
this.leftColumn.add(this.columnSplitter);
this.leftColumn.add(this.hierarchy, 1 - this.assetsShare);
// A share rather than a height, so a taller or shorter window keeps the split the user chose.
@@ -357,15 +368,19 @@ export class Editor implements PlayHost {
root.add(this.workspace, 1);
root.resize(app.screen.width, app.screen.height);
attachAutosave(state, store);
installShortcuts(state, {
play: () => this.play.play(),
stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(),
copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(),
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(),
});
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
this.autosave = attachAutosave(state, store);
this.cleanups.push(
installShortcuts(state, {
play: () => this.play.play(),
stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(),
copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(),
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(),
}),
);
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
app.ticker.add(onTick);
this.cleanups.push(() => app.ticker.remove(onTick));
// A node, a frame and a template object exist only in their own document, so selecting one
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
// creating it) is for – see the `open` event below.
@@ -590,6 +605,33 @@ export class Editor implements PlayHost {
return this.projects.boot();
}
/**
* Takes the editor off the screen and lets go of everything it registered elsewhere – ticker,
* shortcuts, file drops, plugins, the autosave. Signing out does this instead of reloading the
* page (#177): the canvas, the renderer and the UI context stay, only what is drawn changes.
*
* Unsaved work is written first, while the session that owns it is still good.
*/
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
await this.autosave.flush();
this.autosave.stop();
if (this.play.isPlaying) this.play.stop();
this.editorRun.shutDown();
await this.plugins?.stopAll();
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
// A menu or a picker left open belongs to widgets that are about to be destroyed.
for (const popup of ui().overlay.removeChildren()) popup.destroy({ children: true });
ui().focus.blur();
this.root.removeChild(this.toolbar, this.workspace);
this.toolbar.destroy({ children: true });
this.workspace.destroy({ children: true });
// The sign-in screen sets `root` to the size of the window as well, and `resize` lays out only
// when the size changed – the same reason `App.ts` forgets the size after signing in.
this.root.resize(0, 0);
}
showGame(): void {
this.showTab('game');
}
+6
View File
@@ -162,6 +162,12 @@ export class EditorRun {
this.writing = false;
}
/** Ends the run for good, sounds and world included: the editor is going away (#177). */
shutDown(): void {
this.stop();
this.stale = false;
}
private stop(): void {
if (!this.world) return;
// Its sounds end with it, for the reason a played run's do – an editor run is restarted by any
+132
View File
@@ -0,0 +1,132 @@
import { Graphics } from 'pixi.js';
import { type AuthStore, WrongCredentialsError } from '../../core/store/AuthStore';
import { Button, drawControlBox, Label, requestRender, Spacer, TextField, Theme, ui, VStack } from '../../ui';
import { loginError, loginProblem } from './loginRules';
const CARD_WIDTH = 280;
/** The box in the middle of the screen; the screen around it is the plain editor background. */
class Card extends VStack {
private readonly bg = new Graphics();
constructor() {
super({ padding: Theme.padding * 2, gap: Theme.gap });
this.addChildAt(this.bg, 0);
this.minWidth = CARD_WIDTH;
}
override redraw(): void {
this.bg.clear();
drawControlBox(this.bg, this.w, this.h, { fill: Theme.colors.panel, border: Theme.colors.border });
}
}
/**
* Sign-in screen, drawn in the same canvas as the editor: there is no second canvas and no HTML
* form, so it looks and behaves like every other part of the editor.
*
* It is shown instead of the editor, not over it – nothing of a project is loaded while nobody is
* signed in, so there is nothing behind it to hide.
*/
export class LoginScreen extends VStack {
private readonly user: TextField;
private readonly password: TextField;
private readonly message: Label;
private readonly submit: Button;
private busy = false;
constructor(
private readonly auth: AuthStore,
private readonly onSignedIn: (user: string) => void,
) {
super({ align: 'center', gap: Theme.gap });
this.add(new Spacer(), 1);
const card = new Card();
card.add(new Label('Game Maker', { style: 'title' }));
card.add(new Label('Sign in to open your projects', { style: 'muted' }));
card.add(new Spacer(Theme.gap));
card.add(new Label('User name', { style: 'small' }));
this.user = new TextField('', {
width: CARD_WIDTH - Theme.padding * 4,
placeholder: 'User name',
onSubmit: () => ui().focus.focus(this.password),
});
card.add(this.user);
card.add(new Label('Password', { style: 'small' }));
this.password = new TextField('', {
width: CARD_WIDTH - Theme.padding * 4,
placeholder: 'Password',
secret: true,
onSubmit: () => void this.signIn(),
});
card.add(this.password);
this.message = new Label('', { style: 'warning' });
card.add(this.message);
this.submit = new Button('Sign in', {
variant: 'primary',
width: CARD_WIDTH - Theme.padding * 4,
onClick: () => void this.signIn(),
});
card.add(this.submit);
this.add(card);
this.add(new Spacer(), 1);
}
/** Puts the caret in the first field, so a keyboard alone can sign in. */
focusFirstField(): void {
ui().focus.focus(this.user);
}
/** What the check scripts read: the message under the form and whether it is waiting. */
info(): { message: string; busy: boolean } {
return { message: this.message.text, busy: this.busy };
}
private async signIn(): Promise<void> {
if (this.busy) return;
const username = this.user.value.trim();
const password = this.password.value;
const problem = loginProblem(username, password);
if (problem) {
this.say(problem);
this.refocus(username === '' ? this.user : this.password);
return;
}
this.setBusy(true);
try {
const user = await this.auth.logIn(username, password);
this.onSignedIn(user);
} catch (error) {
this.say(loginError(error, error instanceof WrongCredentialsError));
this.setBusy(false);
// A second attempt starts where the first one went wrong, not in an empty form.
this.refocus(this.password);
}
}
/**
* Puts the caret back into a field after a refused attempt. A field blurs itself once its Enter
* has been handled, and that happens after this call returns – so the focus has to be set again
* behind it, or the next thing typed goes nowhere.
*/
private refocus(field: TextField): void {
queueMicrotask(() => ui().focus.focus(field));
}
private setBusy(busy: boolean): void {
this.busy = busy;
this.submit.setText(busy ? 'Signing in…' : 'Sign in');
this.submit.setEnabled(!busy);
requestRender();
}
private say(message: string): void {
this.message.setText(message);
requestRender();
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Application } from 'pixi.js';
import { afterFrames, requestRender } from '../../ui';
import { textProbe } from '../textProbe';
import type { LoginScreen } from './LoginScreen';
/**
* A small `window.__gm` for the sign-in screen: the editor's hook cannot be installed yet, and
* without one the check scripts have no way to find the fields they have to fill in.
*
* It carries no `state` – that is how a check tells the sign-in screen from the editor.
* Returns the call that takes it down again.
*/
export function installLoginHook(app: Application, screen: LoginScreen): () => void {
const { locate, locateAll } = textProbe(app);
const global = window as unknown as { __gm?: unknown };
global.__gm = {
locate,
locateAll,
afterFrames,
wake: () => requestRender(),
login: () => screen.info(),
};
return () => {
delete global.__gm;
};
}
+15
View File
@@ -0,0 +1,15 @@
/**
* What a login form may complain about before anything is sent. Kept apart from the screen so it
* can be read without a browser: the screen only shows what this returns.
*/
export function loginProblem(username: string, password: string): string | null {
if (username.trim() === '') return 'Enter your user name';
if (password === '') return 'Enter your password';
return null;
}
/** The message a failed attempt puts under the form. */
export function loginError(error: unknown, wrongCredentials: boolean): string {
if (wrongCredentials) return 'Wrong user name or password';
return `Could not sign in: ${error instanceof Error ? error.message : String(error)}`;
}
+18 -4
View File
@@ -11,12 +11,14 @@ export function attachAutosave(
state: EditorState,
store: ProjectStore,
delayMs = 1000,
): { flush: () => Promise<void> } {
): { flush: () => Promise<void>; stop: () => void } {
let timer = 0;
let saving: Promise<void> | null = null;
let pending = false;
let stopped = false;
const save = async (): Promise<void> => {
if (stopped) return;
if (saving) {
pending = true;
return saving;
@@ -50,6 +52,7 @@ export function attachAutosave(
};
const schedule = (): void => {
if (stopped) return;
window.clearTimeout(timer);
timer = window.setTimeout(() => void save(), delayMs);
};
@@ -63,12 +66,23 @@ export function attachAutosave(
await save();
};
window.addEventListener('beforeunload', (event) => {
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (state.saveStatus === 'dirty' || state.saveStatus === 'saving') {
void flush();
event.preventDefault();
}
});
};
window.addEventListener('beforeunload', onBeforeUnload);
return { flush };
/**
* Ends the autosave: nothing pending fires afterwards, and the closed-over state is let go of.
* Signing out does this while the page stays up (#177) – it flushes first, then stops.
*/
const stop = (): void => {
stopped = true;
window.clearTimeout(timer);
window.removeEventListener('beforeunload', onBeforeUnload);
};
return { flush, stop };
}
+18 -2
View File
@@ -1,4 +1,13 @@
import { Button, HStack, Label, Panel, ScrollContainer, type TextStyleToken, VStack } from '../../ui';
import {
Button,
HStack,
Label,
Panel,
ScrollContainer,
type TextStyleToken,
VStack,
type Widget,
} from '../../ui';
import { consoleLog, type ConsoleLog, formatTime, type LogLevel } from './log';
const ROW_HEIGHT = 18;
@@ -25,13 +34,20 @@ export class ConsolePanel extends Panel {
private readonly rows = new VStack();
private readonly views: Row[] = [];
private stale = true;
private readonly releaseLog: () => void;
constructor(private readonly log: ConsoleLog = consoleLog) {
super({ title: 'Console', padding: 0, gap: 0 });
this.header.add(new Button('Clear', { variant: 'ghost', onClick: () => this.log.clear() }));
this.scroll.setContent(this.rows);
this.content.add(this.scroll, 1);
this.log.onChange(() => (this.stale = true));
this.releaseLog = this.log.onChange(() => (this.stale = true));
}
/** The log outlives the panel – signing out destroys the panel while the page stays up (#177). */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.releaseLog();
super.destroy(options);
}
refresh(): void {
+3 -1
View File
@@ -98,8 +98,10 @@ export class ConsoleLog {
for (const listener of this.listeners) listener();
}
onChange(listener: () => void): void {
/** Returns the call that takes the listener away again – a panel destroyed on sign-out (#177). */
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
+2 -66
View File
@@ -33,6 +33,7 @@ import { currentSoundPicker } from './sound/SoundPicker';
import { currentImagePicker } from './texture/ImagePicker';
import { currentTilemapWidgets } from './panels/TilemapPanel';
import type { EditorState, TilemapTool } from './state';
import { rectOf, textProbe } from './textProbe';
/**
* Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their
@@ -45,72 +46,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
};
/** The overlap of two global rectangles; an empty one when they do not meet. */
const intersect = (a: Rectangle, b: Rectangle): Rectangle => {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
return new Rectangle(
x,
y,
Math.max(0, Math.min(a.right, b.right) - x),
Math.max(0, Math.min(a.bottom, b.bottom) - y),
);
};
const rectOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x, y: p.y, w: widget.w, h: widget.h };
};
/**
* Every text under `root` that is really on screen, in drawing order (the topmost last), handed
* to `visit` with the centre a click on it would use.
*
* A row scrolled out of its list is still `visible` – only masked away – and reporting it would
* hand a check a point outside the panel, where the click lands on whatever is underneath. That
* is how a step that passes on a short list fails on a long one (#39 in CI), so a text whose
* centre falls outside the clipping it sits in counts as not being there at all.
*
* Every hook that reads text goes through here. A `locate` that refuses a scrolled-away row
* while `overlayTexts` still lists it is worse than either rule alone: a check picks a row off
* the one hook and then cannot find it with the other.
*
* The clip is the box of the *widget* that owns the mask, not the mask itself: Pixi hands a
* graphics that serves as a mask to the mask effect, and `getBounds()` on it then reports an
* empty rectangle at the origin – which would throw away every masked text in the editor.
*/
const eachText = (root: Container, visit: (node: Text, point: { x: number; y: number }) => void): void => {
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
if (!node.visible) return;
const holder = node instanceof Widget ? node : owner;
let inner = clip;
if (node.mask && holder) {
const r = rectOf(holder);
const box = new Rectangle(r.x, r.y, r.w, r.h);
inner = clip ? intersect(clip, box) : box;
}
if (node instanceof Text) {
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
const box = node.getBounds();
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
if (!inner || inner.contains(point.x, point.y)) visit(node, point);
return;
}
for (const child of node.children) walk(child, inner, holder);
};
walk(root, null, null);
};
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => {
const hits: { x: number; y: number }[] = [];
eachText(app.stage, (node, point) => {
if (node.text === text) hits.push(point);
});
return hits;
};
const locate = (text: string, last = false): { x: number; y: number } | null => {
const hits = locateAll(text);
return (last ? hits.at(-1) : hits[0]) ?? null;
};
const { eachText, locate, locateAll } = textProbe(app);
/** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
const textColor = (text: string): string | null => {
let found: string | null = null;
+9 -2
View File
@@ -1,5 +1,5 @@
import type { PropertyBag } from '../../core/model/Properties';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
import { Label, Panel, ScrollContainer, Theme, ui, VStack, type Widget } from '../../ui';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
@@ -32,6 +32,7 @@ export class Inspector extends Panel {
private rebuilds = 0;
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: (assetId: string) => PropertyBag | null = () => null;
private readonly releaseFocusHandler: () => void;
constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 });
@@ -42,12 +43,18 @@ export class Inspector extends Panel {
state.on('load', () => this.rebuild());
state.on('project', () => this.refreshValues());
state.on('runtime', () => this.refreshLive());
ui().focus.changeHandlers.push(() => {
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
});
this.rebuild();
}
/** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.releaseFocusHandler();
super.destroy(options);
}
/** For the check scripts: whether a rebuild is being held back, and how many have run. */
debugInfo(): { rebuilds: number; postponed: boolean } {
return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft };
+25 -6
View File
@@ -34,6 +34,7 @@ import {
Theme,
ui,
VStack,
type Widget,
} from '../../ui';
import type { EditorState, TilemapFocus, TilemapTool } from '../state';
import { TilePalette } from '../tile/TilePalette';
@@ -164,6 +165,8 @@ export class TilemapPanel extends Panel {
/** As set, before the header upper-cases it – for the check scripts. */
title = 'Tilemap';
onTargetChange: () => void = () => {};
/** What this panel registered outside itself, as the calls that take it away again. */
private readonly cleanups: (() => void)[] = [];
constructor(private readonly state: EditorState) {
super({ title: 'Tilemap', padding: 0, gap: 0 });
@@ -177,20 +180,36 @@ export class TilemapPanel extends Panel {
if (ui().focus.isWithin(this)) this.rebuildWhenLeft = true;
else this.sync();
});
ui().focus.changeHandlers.push(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.sync();
});
this.cleanups.push(
ui().focus.onFocusChange(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.sync();
}),
);
// Holding Alt picks (#133). The focus manager only hears presses, so the release has its own
// listener; a handled Alt is kept from the browser, which would otherwise open its menu bar.
const onAlt = (held: boolean) => (event: KeyboardEvent) => {
if (event.key === 'Alt' && !event.repeat && holdPick(state, held)) event.preventDefault();
};
window.addEventListener('keydown', onAlt(true));
window.addEventListener('keyup', onAlt(false));
window.addEventListener('blur', () => holdPick(state, false));
const onKeyDown = onAlt(true);
const onKeyUp = onAlt(false);
const onBlur = () => holdPick(state, false);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
this.cleanups.push(() => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
});
this.sync();
}
/** Signing out destroys the panel while the page stays up (#177): its listeners go with it. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
for (const cleanup of this.cleanups.splice(0)) cleanup();
super.destroy(options);
}
/** Whether there is a map to paint – the editor adds "and the scene tab is showing". */
get hasTarget(): boolean {
return built !== null;
+7 -2
View File
@@ -14,6 +14,8 @@ export interface ToolbarActions {
togglePlay(): void;
/** Hold or resume the running world; only reachable in play mode. */
togglePause(): void;
/** Ends the session and returns to the sign-in screen. */
logOut(): void;
}
const SAVE_LABELS: Record<SaveStatus, string> = {
@@ -92,15 +94,18 @@ export class Toolbar extends HStack {
this.add(this.pauseButton);
this.add(new Spacer(), 1);
// Flexible, not a fixed strip: it is the one thing here that may shrink, and since the Logout
// button (#177) the row no longer fits a narrow window otherwise.
this.notice = new Label('', { style: 'small', align: 'right' });
this.notice.minWidth = 260;
this.add(this.notice);
this.add(this.notice, 1);
this.status = new Label('', { style: 'small', align: 'right' });
this.status.minWidth = 110;
this.add(this.status);
this.fps = new Label('– fps', { style: 'small', align: 'right' });
this.fps.minWidth = 56;
this.add(this.fps);
// Far right, where the account lives; who is signed in stands in the window title.
this.add(new Button('Logout', { onClick: () => actions.logOut() }));
state.on('project', () => this.refresh());
state.on('load', () => this.refresh());
+14
View File
@@ -136,6 +136,20 @@ export class PluginManager {
});
}
/**
* Deactivates everything that is running, without touching the switches on the server: the
* editor is going away (signing out, #177) and every plugin has to let go of it first. A later
* editor starts them again from the same files.
*/
stopAll(): Promise<void> {
return this.enqueue(async () => {
for (const file of [...this.loaded.keys()]) this.deactivate(file);
this.started = false;
this.changed();
await Promise.resolve();
});
}
/** Switches a plugin on or off: stored on the server first, and only then applied. */
setActive(file: string, active: boolean): Promise<void> {
return this.enqueue(async () => {
+4 -3
View File
@@ -15,10 +15,11 @@ export interface ShortcutActions {
/**
* Global keyboard shortcuts (see the wiki page "Keyboard"). Text fields keep their own key handling;
* these only run when no focused widget consumed the key.
* these only run when no focused widget consumed the key. Returns the call that takes them away
* again – signing out tears the editor down while the page stays up (#177).
*/
export function installShortcuts(state: EditorState, actions: ShortcutActions): void {
ui().focus.globalHandlers.push((event) => {
export function installShortcuts(state: EditorState, actions: ShortcutActions): () => void {
return ui().focus.onGlobalKey((event) => {
const mod = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (event.key === 'Escape' && state.mode === 'play') {
+88
View File
@@ -0,0 +1,88 @@
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
import { Widget } from '../ui';
/** The global box of a widget: where it starts, and how large it is. */
export function rectOf(widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }): {
x: number;
y: number;
w: number;
h: number;
} {
const p = widget.getGlobalPosition();
return { x: p.x, y: p.y, w: widget.w, h: widget.h };
}
/** The overlap of two global rectangles; an empty one when they do not meet. */
function intersect(a: Rectangle, b: Rectangle): Rectangle {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
return new Rectangle(
x,
y,
Math.max(0, Math.min(a.right, b.right) - x),
Math.max(0, Math.min(a.bottom, b.bottom) - y),
);
}
/**
* Finding widgets by the text they show – the ground floor of the test hook, and the only part of
* it the sign-in screen needs, since it exists before there is an editor to ask.
*/
export function textProbe(app: Application): {
eachText: (root: Container, visit: (node: Text, point: { x: number; y: number }) => void) => void;
locateAll: (text: string) => { x: number; y: number }[];
locate: (text: string, last?: boolean) => { x: number; y: number } | null;
} {
/**
* Every text under `root` that is really on screen, in drawing order (the topmost last), handed
* to `visit` with the centre a click on it would use.
*
* A row scrolled out of its list is still `visible` – only masked away – and reporting it would
* hand a check a point outside the panel, where the click lands on whatever is underneath. That
* is how a step that passes on a short list fails on a long one (#39 in CI), so a text whose
* centre falls outside the clipping it sits in counts as not being there at all.
*
* Every hook that reads text goes through here. A `locate` that refuses a scrolled-away row
* while `overlayTexts` still lists it is worse than either rule alone: a check picks a row off
* the one hook and then cannot find it with the other.
*
* The clip is the box of the *widget* that owns the mask, not the mask itself: Pixi hands a
* graphics that serves as a mask to the mask effect, and `getBounds()` on it then reports an
* empty rectangle at the origin – which would throw away every masked text in the editor.
*/
const eachText = (root: Container, visit: (node: Text, point: { x: number; y: number }) => void): void => {
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
if (!node.visible) return;
const holder = node instanceof Widget ? node : owner;
let inner = clip;
if (node.mask && holder) {
const r = rectOf(holder);
const box = new Rectangle(r.x, r.y, r.w, r.h);
inner = clip ? intersect(clip, box) : box;
}
if (node instanceof Text) {
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
const box = node.getBounds();
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
if (!inner || inner.contains(point.x, point.y)) visit(node, point);
return;
}
for (const child of node.children) walk(child, inner, holder);
};
walk(root, null, null);
};
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => {
const hits: { x: number; y: number }[] = [];
eachText(app.stage, (node, point) => {
if (node.text === text) hits.push(point);
});
return hits;
};
const locate = (text: string, last = false): { x: number; y: number } | null => {
const hits = locateAll(text);
return (last ? hits.at(-1) : hits[0]) ?? null;
};
return { eachText, locateAll, locate };
}
+22
View File
@@ -22,6 +22,20 @@ export class FocusManager {
return this.focusedWidget;
}
/**
* Registers a handler for keys nobody consumed and returns the call that takes it away again.
* Signing out tears the editor down while the page stays up (#177), so everything it registered
* here has to go with it – a handler left behind would keep typing into a dead editor.
*/
onGlobalKey(handler: (event: KeyboardEvent) => boolean): () => void {
return remover(this.globalHandlers, handler);
}
/** The same for "focus moved", used by panels that postpone work while a field is being typed in. */
onFocusChange(handler: () => void): () => void {
return remover(this.changeHandlers, handler);
}
focus(widget: Widget | null): void {
if (widget === this.focusedWidget) return;
const previous = this.focusedWidget;
@@ -93,6 +107,14 @@ export class FocusManager {
}
}
function remover<T>(list: T[], entry: T): () => void {
list.push(entry);
return () => {
const index = list.indexOf(entry);
if (index >= 0) list.splice(index, 1);
};
}
function isAttached(node: Container, root: Container): boolean {
let current: Container | null = node;
while (current) {
+15 -2
View File
@@ -19,8 +19,13 @@ export interface TextFieldOptions {
* semantics, unlike `onChange`, which also fires when a click elsewhere takes the focus.
*/
onSubmit?: (value: string) => void;
/** A password: drawn as bullets, and never on screen as itself. */
secret?: boolean;
}
/** What a masked field shows per character. */
const BULLET = '•';
/**
* Single-line text input drawn in the canvas. Keyboard handling is delegated to the
* KeyboardBridge while focused; this class only mirrors value, selection and caret.
@@ -42,6 +47,7 @@ export class TextField extends Widget implements TextEditTarget {
private blinkTimer = 0;
private dragSelecting = false;
placeholder: string;
private readonly secret: boolean;
onInput?: (value: string) => void;
onChange?: (value: string) => void;
onSubmit?: (value: string) => void;
@@ -53,6 +59,7 @@ export class TextField extends Widget implements TextEditTarget {
this.onInput = options.onInput;
this.onChange = options.onChange;
this.onSubmit = options.onSubmit;
this.secret = options.secret ?? false;
this.focusable = true;
this.cursor = 'text';
this.minHeight = Theme.controlHeight;
@@ -207,8 +214,14 @@ export class TextField extends Widget implements TextEditTarget {
if (ui().focus.focused === this) ui().focus.blur();
}
/** What the field puts on screen for `text`: the text itself, or one bullet per character. */
protected shown(text: string): string {
return this.secret ? BULLET.repeat(text.length) : text;
}
// Measured on what is drawn, so caret and selection of a masked field sit on the bullets.
private textWidth(text: string): number {
return CanvasTextMetrics.measureText(text, this.textNode.style).width;
return CanvasTextMetrics.measureText(this.shown(text), this.textNode.style).width;
}
/** Character index closest to a local x coordinate. */
@@ -258,7 +271,7 @@ export class TextField extends Widget implements TextEditTarget {
.rect(1, 1, this.w - 2, this.h - 2)
.fill(0xffffff);
this.textNode.text = this.valueText;
this.textNode.text = this.shown(this.valueText);
this.placeholderNode.text = this.valueText ? '' : this.placeholder;
// Horizontal scroll so the caret stays visible.