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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60ea56a2ce
commit
8e4bb6e1f9
38 files changed
+1530
-139
No files matched your search
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user