Three stores, three importers and three pickers that differ only in their type are one of each (#85)
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts, and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list, check count 958, the two-job CI, the Scenes wiki page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
15bdfdf07a
commit
cc6db5f350
42 files changed
+1375
-1650
No files matched your search
+64
-250
@@ -1,296 +1,110 @@
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import type { StoredFont } from '../../core/store/FontStore';
|
||||
import {
|
||||
Button,
|
||||
drawControlBox,
|
||||
formatBytes,
|
||||
HStack,
|
||||
Label,
|
||||
Popup,
|
||||
pressCount,
|
||||
ScrollContainer,
|
||||
type Size,
|
||||
Spacer,
|
||||
Theme,
|
||||
textStyle,
|
||||
truncate,
|
||||
Widget,
|
||||
} from '../../ui';
|
||||
import { formatBytes, Theme, textStyle, truncate } from '../../ui';
|
||||
import { fontLibrary } from '../../viewport/FontLibrary';
|
||||
import {
|
||||
currentPicker,
|
||||
FilePicker,
|
||||
openPicker,
|
||||
type PickerItem,
|
||||
type PickerRow,
|
||||
RowList,
|
||||
withAssetNames,
|
||||
} from '../FilePicker';
|
||||
import { type FontImportDeps, importStoredFont, pickAndImportFonts } from '../fontImport';
|
||||
|
||||
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
||||
const LIST_WIDTH = 560;
|
||||
const LIST_HEIGHT = 340;
|
||||
const ROW_HEIGHT = 52;
|
||||
const ROW_GAP = 4;
|
||||
const ROW_PAD = 8;
|
||||
/** Big enough to tell two typefaces apart at a glance, small enough for a long line to fit. */
|
||||
const SAMPLE_SIZE = 22;
|
||||
const SAMPLE_TEXT = 'Aa Bb Cc 0123 The quick brown fox';
|
||||
|
||||
/** One offered typeface, with the asset that already wears it (if any). */
|
||||
interface PickerItem extends StoredFont {
|
||||
assetName: string | null;
|
||||
type FontItem = PickerItem<StoredFont>;
|
||||
interface FontRow extends PickerRow {
|
||||
sample: Text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows. A typeface has no thumbnail to show, so each row *is* the preview: the sample line is
|
||||
* drawn in the face itself, and reflows from the fallback once the library has loaded it.
|
||||
* A typeface has no thumbnail to show, so each row *is* the preview: the sample line is drawn in
|
||||
* the face itself, and reflows from the fallback once the library has loaded it.
|
||||
*/
|
||||
class FontList extends Widget {
|
||||
private items: PickerItem[] = [];
|
||||
private rows: { box: Container; background: Graphics; sample: Text; caption: Text }[] = [];
|
||||
private selectedFile: string | null = null;
|
||||
private readonly empty = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly stopWatching: () => void;
|
||||
onPick?: (file: string) => void;
|
||||
onActivate?: (file: string) => void;
|
||||
emptyText = '';
|
||||
class FontList extends RowList<FontItem, FontRow> {
|
||||
protected readonly rowHeight = ROW_HEIGHT;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.empty);
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
|
||||
this.stopWatching = fontLibrary.onChange(() => this.redraw());
|
||||
this.watch(fontLibrary, () => this.redraw());
|
||||
}
|
||||
|
||||
setItems(items: PickerItem[]): void {
|
||||
this.items = items;
|
||||
for (const row of this.rows) row.box.destroy({ children: true });
|
||||
this.rows = items.map(() => {
|
||||
// A Container holding the background, not a Graphics with children: Pixi v8 only lets
|
||||
// containers have them (`addChild: Only Containers will be allowed to add children`).
|
||||
const box = new Container();
|
||||
const background = new Graphics();
|
||||
// Its own style object, never `textStyle('body')`: those are shared across the whole
|
||||
// editor, so writing a family into one would restyle every label in the window.
|
||||
const sample = new Text({
|
||||
style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }),
|
||||
});
|
||||
const caption = new Text({ style: textStyle('small') });
|
||||
box.addChild(background, sample, caption);
|
||||
this.addChild(box);
|
||||
return { box, background, sample, caption };
|
||||
});
|
||||
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
|
||||
this.layout();
|
||||
this.redraw();
|
||||
protected buildRow(): FontRow {
|
||||
const box = new Container();
|
||||
const background = new Graphics();
|
||||
// Its own style object, never `textStyle('body')`: those are shared across the whole editor,
|
||||
// so writing a family into one would restyle every label in the window.
|
||||
const sample = new Text({ style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }) });
|
||||
const caption = new Text({ style: textStyle('small') });
|
||||
box.addChild(background, sample, caption);
|
||||
return { box, background, sample, caption };
|
||||
}
|
||||
|
||||
get selected(): PickerItem | null {
|
||||
return this.items.find((item) => item.file === this.selectedFile) ?? null;
|
||||
}
|
||||
protected redrawRow(row: FontRow, item: FontItem, selected: boolean): void {
|
||||
this.drawBox(row, this.w, ROW_HEIGHT, selected);
|
||||
// Empty family = not loaded yet; the row then reads in the editor's own typeface and the
|
||||
// library's change event brings it back here once the face has arrived.
|
||||
row.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font;
|
||||
row.sample.text = truncate(SAMPLE_TEXT, row.sample.style, this.w - ROW_PAD * 2);
|
||||
row.sample.position.set(ROW_PAD, ROW_PAD - 2);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Global centre of a row; null when the file is not offered (test hook). */
|
||||
rowGlobal(file: string): { x: number; y: number } | null {
|
||||
const index = this.items.findIndex((item) => item.file === file);
|
||||
const row = this.rows[index];
|
||||
if (!row) return null;
|
||||
const p = row.box.getGlobalPosition();
|
||||
return { x: p.x + this.w / 2, y: p.y + ROW_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) };
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP)));
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.empty.text = this.items.length === 0 ? this.emptyText : '';
|
||||
this.empty.position.set(0, 0);
|
||||
|
||||
this.rows.forEach((node, index) => {
|
||||
const item = this.items[index];
|
||||
if (!item) return;
|
||||
const selected = item.file === this.selectedFile;
|
||||
node.background.clear();
|
||||
drawControlBox(node.background, this.w, ROW_HEIGHT, {
|
||||
fill: selected ? Theme.colors.selection : Theme.colors.control,
|
||||
border: selected ? Theme.colors.accent : Theme.colors.border,
|
||||
});
|
||||
|
||||
// Empty family = not loaded yet; the row then reads in the editor's own typeface and the
|
||||
// library's change event brings it back here once the face has arrived.
|
||||
node.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font;
|
||||
node.sample.text = truncate(SAMPLE_TEXT, node.sample.style, this.w - ROW_PAD * 2);
|
||||
node.sample.position.set(ROW_PAD, ROW_PAD - 2);
|
||||
|
||||
// The asset name wins where there is one – it is what the project calls this face. Otherwise
|
||||
// the name it was uploaded under, which is all a bare hash leaves to go on.
|
||||
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
|
||||
node.caption.text = truncate(
|
||||
item.assetName ?? (item.name || formatBytes(item.bytes)),
|
||||
node.caption.style,
|
||||
this.w - ROW_PAD * 2,
|
||||
);
|
||||
node.caption.position.set(ROW_PAD, ROW_HEIGHT - 16);
|
||||
});
|
||||
}
|
||||
|
||||
override destroy(): void {
|
||||
this.stopWatching();
|
||||
super.destroy({ children: true });
|
||||
}
|
||||
|
||||
private onPress(e: FederatedPointerEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP));
|
||||
const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined;
|
||||
if (!item) return;
|
||||
this.select(item.file);
|
||||
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
|
||||
// The asset name wins where there is one – it is what the project calls this face. Otherwise
|
||||
// the name it was uploaded under, which is all a bare hash leaves to go on.
|
||||
row.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
|
||||
row.caption.text = truncate(
|
||||
item.assetName ?? (item.name || formatBytes(item.bytes)),
|
||||
row.caption.style,
|
||||
this.w - ROW_PAD * 2,
|
||||
);
|
||||
row.caption.position.set(ROW_PAD, ROW_HEIGHT - 16);
|
||||
}
|
||||
}
|
||||
|
||||
function heightFor(count: number): number {
|
||||
return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers every typeface the server holds, the way the image picker offers pictures: uploading the
|
||||
* same font twice is wasteful, and nothing collects the files a project stopped referencing.
|
||||
*/
|
||||
export class FontPicker extends Popup {
|
||||
private readonly list = new FontList();
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly status: Label;
|
||||
private readonly detail: Label;
|
||||
private readonly use: Button;
|
||||
export class FontPicker extends FilePicker<FontItem> {
|
||||
declare protected readonly list: FontList;
|
||||
|
||||
constructor(private readonly deps: FontImportDeps) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.content.add(new Label('Choose a font', { style: 'bodyBold' }));
|
||||
this.status = new Label('Loading fonts…', { style: 'muted' });
|
||||
this.content.add(this.status);
|
||||
|
||||
this.list.minWidth = LIST_WIDTH;
|
||||
this.list.emptyText = 'No fonts on the server yet – upload one.';
|
||||
this.list.onPick = () => this.refreshSelection();
|
||||
this.list.onActivate = () => this.confirm();
|
||||
this.scroll.minWidth = LIST_WIDTH;
|
||||
this.scroll.minHeight = LIST_HEIGHT;
|
||||
this.scroll.setContent(this.list);
|
||||
this.content.add(this.scroll);
|
||||
|
||||
this.detail = new Label('', { style: 'muted' });
|
||||
this.content.add(this.detail);
|
||||
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
const upload = new Button('Upload…');
|
||||
upload.onClick = this.closeThen(() => pickAndImportFonts(this.deps));
|
||||
buttons.add(upload);
|
||||
buttons.add(new Spacer(), 1);
|
||||
const cancel = new Button('Cancel');
|
||||
cancel.onClick = this.closeThen();
|
||||
buttons.add(cancel);
|
||||
this.use = new Button('Use font', { variant: 'primary' });
|
||||
this.use.onClick = () => this.confirm();
|
||||
this.use.setEnabled(false);
|
||||
buttons.add(this.use);
|
||||
this.content.add(buttons);
|
||||
|
||||
void this.loadList();
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
super(
|
||||
new FontList(),
|
||||
{
|
||||
title: 'Choose a font',
|
||||
loading: 'Loading fonts…',
|
||||
empty: 'No fonts on the server yet – upload one.',
|
||||
use: 'Use font',
|
||||
noun: 'font',
|
||||
},
|
||||
() => pickAndImportFonts(deps),
|
||||
);
|
||||
}
|
||||
|
||||
rowGlobal(file: string): { x: number; y: number } | null {
|
||||
return this.list.rowGlobal(file);
|
||||
}
|
||||
|
||||
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 listener must be dropped exactly once.
|
||||
if (!this.list.destroyed) this.list.destroy();
|
||||
protected async fetchItems(): Promise<FontItem[]> {
|
||||
return withAssetNames(await this.deps.fonts.list(), this.deps.state.project, 'font');
|
||||
}
|
||||
|
||||
private async loadList(): Promise<void> {
|
||||
try {
|
||||
const stored = await this.deps.fonts.list();
|
||||
const named = new Map<string, string>();
|
||||
for (const asset of this.deps.state.project.assets) {
|
||||
if (asset.type === 'font') named.set(asset.file, asset.name);
|
||||
}
|
||||
this.list.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null })));
|
||||
this.scroll.minHeight = Math.min(
|
||||
this.roomFor(this.scroll, ROW_HEIGHT),
|
||||
Math.max(ROW_HEIGHT, heightFor(stored.length)),
|
||||
);
|
||||
this.status.setText(
|
||||
stored.length === 1 ? '1 font on the server' : `${stored.length} fonts on the server`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.status.setText(
|
||||
`Could not list the fonts: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
this.refreshSelection();
|
||||
protected contentHeight(count: number): { unit: number; total: number } {
|
||||
return { unit: ROW_HEIGHT, total: this.list.heightFor(count) };
|
||||
}
|
||||
|
||||
private refreshSelection(): void {
|
||||
const item = this.list.selected;
|
||||
this.use.setEnabled(item !== null);
|
||||
this.detail.setText(item ? describe(item) : '');
|
||||
this.refreshLayout();
|
||||
}
|
||||
|
||||
private confirm(): void {
|
||||
const item = this.list.selected;
|
||||
if (!item) return;
|
||||
this.close();
|
||||
protected adopt(item: FontItem): void {
|
||||
importStoredFont(item, this.deps);
|
||||
}
|
||||
}
|
||||
|
||||
function describe(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;
|
||||
}
|
||||
|
||||
let current: FontPicker | null = null;
|
||||
|
||||
export function openFontPicker(deps: FontImportDeps): FontPicker {
|
||||
current?.close();
|
||||
current = new FontPicker(deps);
|
||||
current.openCentered();
|
||||
return current;
|
||||
return openPicker(new FontPicker(deps));
|
||||
}
|
||||
|
||||
/** The open dialog, for the test hook. */
|
||||
export function currentFontPicker(): FontPicker | null {
|
||||
return current;
|
||||
return currentPicker(FontPicker);
|
||||
}
|
||||
Reference in new issue
Block a user