Files
feuerwerk/src/editor/FilePicker.ts
T
adminandClaude Opus 5 ae3aa7d3bd
game-maker / gate (push) Successful in 2m0s
game-maker / suite (push) Successful in 7m17s
Refactoring round 5 covers the code since round 4 without changing behaviour: four identical copies of dot() and three of "nearest point on a segment" stood in the collision math, in the hit area of the collider outlines and in the polygon handles, so they moved into the new module src/core/util/vec.ts; the warning line, the busy state, info() and the caret reset were written out line by line in both the sign-in screen and the password dialog and are now FormStatus; the closing row "Cancel plus a confirming button, optionally one on the left" was built by hand in FormDialog, FilePicker, OpenProjectDialog and PasswordDialog and is now dialogButtons(); the header bar was drawn by the toolbar, the TabHeader and the Breadcrumb each on its own and is now drawHeaderBar(); AddColliderCommand and AddRigidbodyCommand share a base, each subclass only builds its own asset; Editor.ts derives the panes from assetEditors instead of repeating eight entries and clamps its splitters with clamp(); jscpd over src falls from 0.19 % to 0.14 %; dead code is gone - listScenes() has been orphaned since the Scene picker fell away (#183), DEFAULT_FILTER (#152) was never used, the two test hooks projectPickerRowAt/rowPosition and particleCompiles are read by no tool, and four functions lost an export they did not need; seven comments that told history ("used to", "no longer", "since #...") describe the state of things now, and that this changed nothing was proved by a rebuild - the bundle is md5 identical, b4ad0dfe... before and after; the README said Logout sits at the far right of the toolbar, which has been the Account menu since #182, and wiki/Architecture.md was missing src/core/util/ altogether; 4 new unit tests cover vec.ts, and switching the clamp on the segment off makes exactly one of them fail while none of the 76 neighbouring tests notice, so that clamping was covered nowhere before (#85)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:24:42 +02:00

331 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The dialog behind "+ → Texture / Font / Sound": everything the server holds of one kind, with
* what the project already calls it. Offering the server's files rather than the machine's is the
* point – uploading the same file twice is wasteful, and nothing collects the files a project
* stopped referencing. Uploading is still one button away.
*
* The list differs per kind (tiles for pictures, rows with a sample line for typefaces, rows with a
* ▶ for sounds); the dialog around it does not.
*/
import { type Container, type FederatedPointerEvent, type Graphics, Text } from 'pixi.js';
import type { AssetType } from '../core/model/AssetTypes';
import type { Project } from '../core/model/Project';
import type { StoredFile } from '../core/store/FileStore';
import {
Button,
drawControlBox,
formatBytes,
Label,
Popup,
pressCount,
ScrollContainer,
Theme,
textStyle,
Widget,
} from '../ui';
import { dialogButtons } from './FormDialog';
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
export const PICKER_WIDTH = 560;
export const PICKER_HEIGHT = 340;
/** One offered file, with the asset that already wears it (if any). */
export type PickerItem<S extends StoredFile = StoredFile> = S & { assetName: string | null };
/** Pairs what the server lists with what the project calls it – the asset name is the caption worth showing. */
export function withAssetNames<S extends StoredFile>(
stored: S[],
project: Project,
type: AssetType,
): PickerItem<S>[] {
const named = new Map<string, string>();
for (const asset of project.assets) {
if (asset.type === type && 'file' in asset) named.set(asset.file, asset.name);
}
return stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null }));
}
/** The line under the list for a picked file: its upload name and size, and who wears it. */
export function describeStored(item: PickerItem): string {
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
export interface PickerRow {
/** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */
box: Container;
background: Graphics;
caption: Text;
}
/**
* The selectable part of the dialog: one entry per file, one of them picked, a double press
* confirms. How entries are built, placed and drawn is the subclass's.
*/
export abstract class PickerList<Item extends PickerItem, Row extends PickerRow> extends Widget {
protected items: Item[] = [];
protected rows: Row[] = [];
private selectedFile: string | null = null;
private readonly empty = new Text({ text: '', style: textStyle('muted') });
private readonly unwatch: (() => void)[] = [];
onPick?: (file: string) => void;
onActivate?: (file: string) => void;
emptyText = '';
constructor() {
super();
this.addChild(this.empty);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
}
/** Redraws whenever a library reports an arrival, until the list is destroyed. */
protected watch(library: { onChange(fn: () => void): () => void }, onArrive: () => void): void {
this.unwatch.push(library.onChange(onArrive));
}
setItems(items: Item[]): void {
this.items = items;
for (const row of this.rows) row.box.destroy({ children: true });
this.rows = items.map(() => {
const row = this.buildRow();
this.addChild(row.box);
return row;
});
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
this.layout();
this.redraw();
}
get selected(): Item | null {
return this.items.find((item) => item.file === this.selectedFile) ?? null;
}
get fileList(): string[] {
return this.items.map((item) => item.file);
}
get usedFiles(): string[] {
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.rows.map((row) => row.caption.text);
}
select(file: string | null): void {
if (file === this.selectedFile) return;
this.selectedFile = file;
this.redraw();
if (file) this.onPick?.(file);
}
/** The row of a file, with its index; null when the file is not offered. */
protected rowOf(file: string): { row: Row; index: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const row = this.rows[index];
return row ? { row, index } : null;
}
override redraw(): void {
this.empty.text = this.items.length === 0 ? this.emptyText : '';
this.empty.position.set(0, 0);
this.rows.forEach((row, index) => {
const item = this.items[index];
if (item) this.redrawRow(row, item, item.file === this.selectedFile);
});
}
override destroy(): void {
for (const stop of this.unwatch) stop();
super.destroy({ children: true });
}
protected drawBox(row: Row, w: number, h: number, selected: boolean): void {
row.background.clear();
drawControlBox(row.background, w, h, {
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
}
protected onPress(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const item = this.items[this.indexAt(local.x, local.y)];
if (!item) return;
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
}
protected abstract buildRow(): Row;
/** Which entry a local point is on; any index without an item means none. */
protected abstract indexAt(x: number, y: number): number;
protected abstract redrawRow(row: Row, item: Item, selected: boolean): void;
}
/** A list of equally tall rows, one under the other. */
export abstract class RowList<Item extends PickerItem, Row extends PickerRow> extends PickerList<Item, Row> {
protected abstract readonly rowHeight: number;
protected readonly rowGap = 4;
/** Global centre of a row (test hook); `fromX` keeps clear of a column that does something else. */
rowGlobal(file: string, fromX = 0): { x: number; y: number } | null {
const found = this.rowOf(file);
if (!found) return null;
const p = found.row.box.getGlobalPosition();
return { x: p.x + fromX + (this.w - fromX) / 2, y: p.y + this.rowHeight / 2 };
}
heightFor(count: number): number {
return count === 0 ? 0 : count * this.rowHeight + (count - 1) * this.rowGap;
}
override measure(): { w: number; h: number } {
return { w: this.minWidth, h: Math.max(this.minHeight, this.heightFor(this.items.length)) };
}
override layout(): void {
this.rows.forEach((row, index) => row.box.position.set(0, index * (this.rowHeight + this.rowGap)));
}
protected override indexAt(x: number, y: number): number {
return x >= 0 && x <= this.w ? Math.floor(y / (this.rowHeight + this.rowGap)) : -1;
}
}
export interface PickerWords {
title: string;
loading: string;
/** Under the list while nothing is picked; a hint for what the rows can do. */
idle?: string;
empty: string;
use: string;
/** "image", "font", "sound" – for "3 fonts on the server". */
noun: string;
}
let current: FilePicker<PickerItem> | null = null;
/**
* Opens the dialog centred, closing whichever picker was open before it, and starts the listing.
* The listing starts here and not in the constructor: it asks the subclass, and a subclass's own
* fields are not assigned until its constructor has run past `super()`.
*/
export function openPicker<P extends FilePicker<PickerItem>>(picker: P): P {
current?.close();
current = picker;
picker.openCentered();
void picker.load();
return picker;
}
/** The open dialog if it is one of `kind`, for the test hooks. */
export function currentPicker<P extends FilePicker<PickerItem>>(
kind: abstract new (...args: never[]) => P,
): P | null {
return current instanceof kind ? current : null;
}
export abstract class FilePicker<Item extends PickerItem> extends Popup {
protected readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
constructor(
protected readonly list: PickerList<Item, PickerRow>,
private readonly words: PickerWords,
upload: () => void,
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.content.add(new Label(words.title, { style: 'bodyBold' }));
this.status = new Label(words.loading, { style: 'muted' });
this.content.add(this.status);
list.minWidth = PICKER_WIDTH;
list.emptyText = words.empty;
list.onPick = () => this.refreshSelection();
list.onActivate = () => this.confirm();
this.scroll.minWidth = PICKER_WIDTH;
this.scroll.minHeight = PICKER_HEIGHT;
this.scroll.setContent(list);
this.content.add(this.scroll);
this.detail = new Label(words.idle ?? '', { style: 'muted' });
this.content.add(this.detail);
const uploadButton = new Button('Upload…');
uploadButton.onClick = this.closeThen(upload);
this.use = new Button(words.use, { variant: 'primary' });
this.use.onClick = () => this.confirm();
this.use.setEnabled(false);
this.content.add(dialogButtons(this.closeThen(), this.use, uploadButton));
}
/** What the dialog offers, which of it the project already wears, and what is picked (test hook). */
debugInfo(): { files: string[]; used: string[]; captions: string[]; selected: string | null } {
return {
files: this.list.fileList,
used: this.list.usedFiles,
captions: this.list.captions,
selected: this.list.selected?.file ?? null,
};
}
override close(): void {
super.close();
if (current === this) current = null;
// Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second
// call; the list's listeners must be dropped exactly once.
if (!this.list.destroyed) this.list.destroy();
}
async load(): Promise<void> {
try {
const items = await this.fetchItems();
this.list.setItems(items);
// Only as tall as the entries need, up to the cap: a handful of files should not leave half
// a dialog of empty box under them.
const { unit, total } = this.contentHeight(items.length);
this.scroll.minHeight = Math.min(this.roomFor(this.scroll, unit), Math.max(unit, total));
const { noun } = this.words;
this.status.setText(
items.length === 1 ? `1 ${noun} on the server` : `${items.length} ${noun}s on the server`,
);
} catch (error) {
this.status.setText(
`Could not list the ${this.words.noun}s: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.refreshSelection();
}
private refreshSelection(): void {
const item = this.list.selected;
this.use.setEnabled(item !== null && this.canUse(item));
this.detail.setText(item ? this.describe(item) : (this.words.idle ?? ''));
this.refreshLayout();
}
private confirm(): void {
const item = this.list.selected;
if (!item || !this.canUse(item)) return;
this.close();
this.adopt(item);
}
/** The server's list, paired with the project's names (`withAssetNames`). */
protected abstract fetchItems(): Promise<Item[]>;
/** How tall the list is for `count` entries, and the height of one – the least the scroll shows. */
protected abstract contentHeight(count: number): { unit: number; total: number };
protected abstract adopt(item: Item): void;
protected canUse(_item: Item): boolean {
return true;
}
protected describe(item: Item): string {
return describeStored(item);
}
}