Fonts: a new asset type, picked from the server or dropped in, worn by text objects (#74)
game-maker / suite (push) Successful in 9m10s

A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.

The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-13 20:09:59 +02:00
1 parent 28c0789b19
commit 86b5363a01
35 files changed
+1451 -85

No files matched your search

+29 -1
View File
@@ -1,7 +1,8 @@
import { createEmptyGraph } from '../graph/Graph';
import { newId } from '../ids';
import type { Asset, TextureAsset } from '../model/Asset';
import type { Asset, FontAsset, TextureAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { removeSubtrees } from '../model/hierarchy';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { objectHolders, type Project } from '../model/Project';
@@ -38,6 +39,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
case 'shader':
return { ...base, type: 'shader', graph: createEmptyGraph() };
case 'font':
return { ...base, type: 'font', file: '' };
}
}
@@ -108,6 +111,31 @@ export class CreateTextureCommand implements Command {
}
}
export function buildFontAsset(name: string, file: string, id = newId()): FontAsset {
return { id, name, props: defaultsFor('font'), type: 'font', file };
}
/** The typeface is uploaded before the command runs, as an image is for `CreateTextureCommand`. */
export class CreateFontCommand implements Command {
readonly label: string;
readonly asset: FontAsset;
constructor(name: string, file: string) {
this.asset = buildFontAsset(name, file);
this.label = `Import font "${name}"`;
}
execute(project: Project): void {
if (!isFontFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored font file`);
project.assets.push(this.asset);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
}
}
/**
* Deletes an asset and, as a cascade, every scene object referencing it, together with the objects
* parented below those – in container templates as well as in scenes.
+12 -1
View File
@@ -100,6 +100,16 @@ export interface ShaderAsset extends AssetBase {
graph: Graph;
}
/**
* `file` names the typeface in `data/fonts/` the way a texture names its image: content-addressed
* `{sha256}.{ext}`, empty when the upload failed. A text object wears it through its `font`
* property; the family the browser knows it under is derived from the file name, see `model/font`.
*/
export interface FontAsset extends AssetBase {
type: 'font';
file: string;
}
export type Asset =
| SpriteAsset
| CameraAsset
@@ -111,7 +121,8 @@ export type Asset =
| TextureAsset
| AnimationAsset
| StateMachineAsset
| ShaderAsset;
| ShaderAsset
| FontAsset;
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset;
+6 -2
View File
@@ -10,6 +10,7 @@ export const ASSET_TYPES = [
'animation',
'statemachine',
'shader',
'font',
] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
@@ -26,15 +27,17 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
animation: 'Animation',
statemachine: 'State machine',
shader: 'Shader',
font: 'Font',
};
/**
* Textures and animations are not placeable: they are pictures a sprite wears through `texture`,
* as a shader is worn through `shader`. A state machine is not either: a behavior refers to it by id.
* as a shader is worn through `shader` and a font through `font`. A state machine is not either:
* a behavior refers to it by id.
*/
export type PlaceableAssetType = Exclude<
AssetType,
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader'
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font'
>;
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
@@ -43,6 +46,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
'animation',
'statemachine',
'shader',
'font',
]);
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
+29
View File
@@ -0,0 +1,29 @@
import type { FontAsset } from './Asset';
import { findAsset, type Project } from './Project';
/** Mirrors `FontStore::isValidName` on the server, where the name is derived from the bytes. */
const FILE_PATTERN = /^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/;
export function isFontFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
/**
* The CSS family a file is registered under. Derived from the content-addressed name, so the same
* bytes are always one family and two uploads of one typeface cannot fight over a name – the name
* inside the font is never used for this, only for the asset's label.
*/
export function fontFamilyOf(file: string): string {
return `gm-font-${file.slice(0, 16)}`;
}
/** The file a `font` property points at, empty when the id is unset or no longer resolves. */
export function fontFileOf(project: Project, id: string): string {
if (id === '') return '';
const asset = findAsset(project, id);
return asset?.type === 'font' ? asset.file : '';
}
export function isFontAsset(asset: { type: string }): asset is FontAsset {
return asset.type === 'font';
}
+5
View File
@@ -22,6 +22,7 @@ import {
import { findAssetOf, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems';
import { FONT_KEY } from './fonts';
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
/**
@@ -189,6 +190,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
shader: [],
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property.
font: [],
collider: [
{
key: 'shape',
@@ -209,6 +212,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
container: [],
text: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
// Empty is the editor's own typeface, the one every text was drawn in before fonts existed.
{ key: FONT_KEY, label: 'Font', kind: 'asset', default: '', assetTypes: ['font'] },
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1, decimals: 0 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{
+8
View File
@@ -0,0 +1,8 @@
import { type PropertyBag, textOf } from '../model/Properties';
/** The text property naming the font asset it wears; `''` is the editor's own typeface. */
export const FONT_KEY = 'font';
export function fontOf(bag: PropertyBag): string {
return textOf(bag[FONT_KEY]);
}
+22
View File
@@ -0,0 +1,22 @@
export interface UploadedFont {
/** Content-addressed name in `data/fonts/`: `{sha256}.{ttf|otf|woff|woff2}` (see `isFontFile`). */
file: string;
bytes: number;
}
/** A typeface the server holds, whether any project references it or not. */
export interface StoredFont {
file: string;
bytes: number;
updatedAt: string;
}
/**
* Raw bytes, like an image upload: the server derives name and format from the file itself. Unlike
* an image a font is never re-encoded on the way, so what the server stores is what was picked.
*/
export interface FontStore {
upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedFont>;
list(): Promise<StoredFont[]>;
delete(file: string): Promise<void>;
}
+29
View File
@@ -0,0 +1,29 @@
import type { FontStore, StoredFont, UploadedFont } from './FontStore';
import { apiRequest } from './http';
export class HttpFontStore implements FontStore {
constructor(private readonly endpoint: string) {}
async upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedFont> {
return this.request<UploadedFont>('POST', 'fonts', { bytes, type });
}
async list(): Promise<StoredFont[]> {
return this.request<StoredFont[]>('GET', 'fonts');
}
async delete(file: string): Promise<void> {
await this.request<void>('DELETE', `fonts/${encodeURIComponent(file)}`);
}
private async request<T>(
method: string,
route: string,
payload?: { bytes: Blob | ArrayBuffer; type: string },
): Promise<T> {
return apiRequest<T>(this.endpoint, method, route, {
headers: payload ? { 'Content-Type': payload.type } : {},
body: payload?.bytes,
});
}
}
+4
View File
@@ -15,6 +15,7 @@ import {
import { isShaderNodeDef } from '../shader/nodes';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import {
type FieldValue,
@@ -116,6 +117,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
} else if (type === 'texture') {
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
} else if (type === 'font') {
const file = typeof a.file === 'string' && isFontFile(a.file) ? a.file : '';
assets.push({ id, type, name, props, file });
} else if (type === 'animation') {
// Kept even when they dangle, like a sprite's `texture`: a frame reads as no picture.
const frames = Array.isArray(a.frames) ? a.frames : [];
+6 -1
View File
@@ -1,7 +1,9 @@
import type { Application } from 'pixi.js';
import { HttpFontStore } from '../core/store/HttpFontStore';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpTextureStore } from '../core/store/HttpTextureStore';
import type { VStack } from '../ui';
import { fontLibrary } from '../viewport/FontLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
@@ -11,20 +13,23 @@ const API_ENDPOINT = '/game-maker/api/index.php';
/**
* Where uploaded images live: the API writes them, the browser reads them straight off disk as
* static files named by their content hash.
* static files named by their content hash. Fonts work the same way, one directory over.
*/
const TEXTURE_BASE = '/game-maker/data/textures';
const FONT_BASE = '/game-maker/data/fonts';
/** Wires state, stores and the editor composition together and opens the first project. */
export async function startEditor(app: Application, root: VStack): Promise<void> {
const state = new EditorState();
textureLibrary.configure(TEXTURE_BASE);
fontLibrary.configure(FONT_BASE);
const editor = new Editor(
app,
root,
state,
new HttpProjectStore(API_ENDPOINT),
new HttpTextureStore(API_ENDPOINT),
new HttpFontStore(API_ENDPOINT),
);
installDebugHook(app, state, editor);
await editor.boot();
+5 -3
View File
@@ -2,6 +2,7 @@ import type { Application } from 'pixi.js';
import type { AssetType } from '../core/model/AssetTypes';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { FontStore } from '../core/store/FontStore';
import type { TextureStore } from '../core/store/TextureStore';
import { Button, clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui';
import { ConsolePanel } from './console/ConsolePanel';
@@ -24,7 +25,7 @@ import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts';
import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport';
import { installFileImport } from './fileImport';
export type CentreTab =
'scene' | 'graph' | 'statemachine' | 'shader' | 'game' | 'texture' | 'animation' | 'container' | 'list';
@@ -120,6 +121,7 @@ export class Editor implements PlayHost {
private readonly state: EditorState,
store: ProjectStore,
textures: TextureStore,
fonts: FontStore,
) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
@@ -156,9 +158,9 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.assets = new AssetPanel(state, textures);
this.assets = new AssetPanel(state, textures, fonts);
this.leftColumn.add(this.assets, 1);
installTextureImport(app.canvas, { state, textures });
installFileImport(app.canvas, { state, textures, fonts });
this.leftColumn.add(this.hierarchy, 1);
this.centre = new VStack();
+4 -2
View File
@@ -8,6 +8,7 @@ import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import type { AssetType } from '../core/model/AssetTypes';
import { isLiveInstance, isTemplateContent } from '../core/model/instance';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import type { SceneObject } from '../core/model/SceneObject';
@@ -68,7 +69,7 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: references > 0 || animations > 0
? wornDetail(references, animations)
? wornDetail(references, animations, asset.type)
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
@@ -80,7 +81,8 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
]).openCentered();
}
function wornDetail(sprites: number, animations: number): string {
function wornDetail(sprites: number, animations: number, type: AssetType): string {
if (type === 'font') return `${sprites} text(s) reference it and fall back to the editor's own typeface.`;
const who = [
sprites > 0 ? `${sprites} sprite(s)` : '',
animations > 0 ? `${animations} animation(s)` : '',
+7
View File
@@ -6,6 +6,7 @@ import { activeDrag, Button, ui, Widget } from '../ui';
import { shaderLibrary } from '../viewport/ShaderLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import type { CentreTab, Editor } from './Editor';
import { currentFontPicker } from './font/FontPicker';
import { currentImagePicker } from './texture/ImagePicker';
import type { EditorState } from './state';
@@ -303,6 +304,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** The open image picker: what it offers, what is picked, and where a tile sits. */
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
/** The same for the font picker, whose rows are the preview. */
fontPicker: () => currentFontPicker()?.debugInfo() ?? null,
fontPickerRow: (file: string) => currentFontPicker()?.rowGlobal(file) ?? null,
/** Which typeface a text ended up drawn in, and how wide it made it. */
textInfo: (id: string) => viewport.textInfo(id),
gameTextInfo: (id: string) => gameView.textInfo(id),
texture: {
current: () => textureEditor.currentAssetId,
pickerWidth: () => textureEditor.picker.w,
+79
View File
@@ -0,0 +1,79 @@
import { type FontImportDeps, importFonts, isFontFileName } from './fontImport';
import { importImages, type TextureImportDeps } from './textureImport';
export type FileImportDeps = TextureImportDeps & FontImportDeps;
/**
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
*
* Pictures and typefaces arrive the same way, so one pair of handlers sorts them apart.
*/
export function installFileImport(target: HTMLElement, deps: FileImportDeps): () => void {
const onDragOver = (event: DragEvent) => {
if (!hasImportableItems(event.dataTransfer)) return;
// Without this the browser navigates away to the dropped file.
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
const images = imageFilesOf(event.dataTransfer);
const fonts = fontFilesOf(event.dataTransfer);
if (images.length === 0 && fonts.length === 0) {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
deps.state.setNotice('Only image and font files can be dropped into the editor.');
return;
}
event.preventDefault();
run(images, fonts, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const images = imageFilesOf(event.clipboardData);
const fonts = fontFilesOf(event.clipboardData);
if (images.length === 0 && fonts.length === 0) return;
event.preventDefault();
run(images, fonts, deps);
};
target.addEventListener('dragover', onDragOver);
target.addEventListener('drop', onDrop);
window.addEventListener('paste', onPaste);
return () => {
target.removeEventListener('dragover', onDragOver);
target.removeEventListener('drop', onDrop);
window.removeEventListener('paste', onPaste);
};
}
/** Images first, then fonts, so a mixed drop ends on one notice instead of two racing ones. */
function run(images: readonly File[], fonts: readonly File[], deps: FileImportDeps): void {
const work = async () => {
if (images.length) await importImages(images, deps);
if (fonts.length) await importFonts(fonts, deps);
};
void work().catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
function imageFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
}
function fontFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => isFontFileName(file.name));
}
/**
* During a drag the files themselves are not readable yet – only kind and type are, and a font
* usually has no type at all. Anything that is not plainly text therefore counts as a candidate;
* the drop decides, and by then the names are readable.
*/
function hasImportableItems(data: DataTransfer | null): boolean {
return [...(data?.items ?? [])].some((item) => item.kind === 'file' && !item.type.startsWith('text/'));
}
function isTextFieldFocused(): boolean {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
}
+285
View File
@@ -0,0 +1,285 @@
import { Container, type FederatedPointerEvent, 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 { fontLibrary } from '../../viewport/FontLibrary';
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;
}
/**
* 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.
*/
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 = '';
constructor() {
super();
this.addChild(this.empty);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
this.stopWatching = fontLibrary.onChange(() => 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();
}
get selected(): PickerItem | 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);
}
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);
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
node.caption.text = truncate(
item.assetName ?? 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);
}
}
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;
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[]; selected: string | null } {
return {
files: this.list.fileList,
used: this.list.usedFiles,
selected: this.list.selected?.file ?? null,
};
}
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();
}
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(LIST_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();
}
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();
importStoredFont(item, this.deps);
}
}
function describe(item: PickerItem): string {
const size = 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;
}
/** The open dialog, for the test hook. */
export function currentFontPicker(): FontPicker | null {
return current;
}
+40
View File
@@ -0,0 +1,40 @@
import { Text, TextStyle } from 'pixi.js';
import { Theme, truncate, type Size, Widget } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary';
const SAMPLE_SIZE = 26;
const SAMPLE_TEXT = 'Aa Bb Cc 0123';
/**
* What a font asset looks like: the only thing about it worth showing, since its file name is a
* hash. Reads in the editor's own typeface until the face has arrived, then redraws itself.
*/
export class FontPreview extends Widget {
private readonly sample = new Text({
style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }),
});
private readonly stopWatching: () => void;
constructor(private readonly file: string) {
super();
this.eventMode = 'none';
this.minHeight = SAMPLE_SIZE + Theme.padding * 2;
this.addChild(this.sample);
this.stopWatching = fontLibrary.onChange(() => this.invalidate());
}
override measure(): Size {
return { w: this.minWidth, h: this.minHeight };
}
override redraw(): void {
this.sample.style.fontFamily = fontLibrary.family(this.file) || Theme.font;
this.sample.text = truncate(SAMPLE_TEXT, this.sample.style, this.w);
this.sample.position.set(0, Theme.padding);
}
override destroy(): void {
this.stopWatching();
super.destroy({ children: true });
}
}
+110
View File
@@ -0,0 +1,110 @@
import { CreateFontCommand } from '../core/commands/assetCommands';
import { nextAssetName } from '../core/commands/naming';
import type { FontAsset } from '../core/model/Asset';
import type { FontStore, UploadedFont } from '../core/store/FontStore';
import type { EditorState } from './state';
import { type ImportOutcome, importNotice } from './textureImport';
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
const FONT_EXTENSION = /\.(ttf|otf|woff|woff2)$/i;
/**
* Fonts are told apart by their name, not their MIME type: browsers report a dropped `.ttf` as
* `font/ttf`, `application/x-font-ttf` or nothing at all, depending on the platform.
*/
export function isFontFileName(name: string): boolean {
return FONT_EXTENSION.test(name);
}
export interface FontImportDeps {
state: EditorState;
fonts: FontStore;
}
/** Sequential like `importImages`, so each asset is named against the project as it then stands. */
export async function importFonts(files: readonly File[], deps: FontImportDeps): Promise<ImportOutcome[]> {
if (files.length === 0) return [];
const outcomes: ImportOutcome[] = [];
for (const file of files) outcomes.push(await importFont(file, deps));
const created = outcomes.filter((outcome) => outcome.assetId !== null);
const last = created[created.length - 1];
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
deps.state.setNotice(importNotice(outcomes, 'font'));
return outcomes;
}
async function importFont(file: File, deps: FontImportDeps): Promise<ImportOutcome> {
try {
if (!isFontFileName(file.name)) throw new Error('Not a font file – use TTF, OTF, WOFF or WOFF2.');
// The bytes go up untouched: unlike a picture, a typeface cannot be re-encoded smaller here.
const uploaded = await deps.fonts.upload(file, file.type || 'application/octet-stream');
return adopt(uploaded, assetNameFor(file), file.name, deps);
} catch (error) {
return {
name: file.name,
assetId: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Makes an asset for a typeface the server holds, or reuses the one already naming those bytes. */
function adopt(font: UploadedFont, nameBase: string, label: string, deps: FontImportDeps): ImportOutcome {
const existing = deps.state.project.assets.find(
(asset): asset is FontAsset => asset.type === 'font' && asset.file === font.file,
);
if (existing) return { name: label, assetId: existing.id, error: null, reused: true };
const name = nextAssetName(deps.state.project, nameBase);
const command = new CreateFontCommand(name, font.file);
// A rejected command has already put its reason into the notice; that is the message here.
if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice };
return { name: label, assetId: command.asset.id, error: null, reused: false };
}
/**
* Adopts a typeface the server already holds – no upload, the bytes are there. Chosen in the
* picker, so this is "+ → Font" and the asset is selected the way a created one would be.
*/
export function importStoredFont(font: UploadedFont, deps: FontImportDeps): ImportOutcome {
const outcome = adopt(font, 'Font', font.file, deps);
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
deps.state.setNotice(
outcome.error ??
(outcome.reused ? 'That font is already in the project.' : 'Added the font to the project.'),
);
return outcome;
}
/** The file's own name without its extension: "Roboto-Bold.ttf" reads better than "Font 3". */
function assetNameFor(file: File): string {
return file.name.replace(/\.[^.]+$/, '').trim() || 'Font';
}
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
export function pickFontFiles(): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = FONT_ACCEPT;
input.multiple = true;
input.style.display = 'none';
document.body.appendChild(input);
const finish = (files: File[]) => {
input.remove();
resolve(files);
};
input.addEventListener('change', () => finish([...(input.files ?? [])]));
// Without `cancel` a dismissed dialog leaves both the element and this promise behind.
input.addEventListener('cancel', () => finish([]));
input.click();
});
}
export function pickAndImportFonts(deps: FontImportDeps): void {
void pickFontFiles()
.then((files) => importFonts(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
+6
View File
@@ -20,6 +20,7 @@ import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../cor
import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Dropdown, HStack, Label, Menu, Theme } from '../../ui';
import { FontPreview } from '../font/FontPreview';
import { aspectActions, sizeCommand } from './aspectAction';
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
@@ -58,6 +59,11 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void {
buildProps(host, asset, markedAssetSchema(host.state.project, asset));
if (asset.type === 'behavior') buildVariables(host, asset);
// A font has no properties at all, so without this its inspector would be the name and nothing.
if (asset.type === 'font')
host.body.add(
asset.file ? new FontPreview(asset.file) : new Label('Not uploaded.', { style: 'warning' }),
);
}
/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */
+21 -3
View File
@@ -15,10 +15,12 @@ import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { spriteFromTexture } from '../../core/properties/aspect';
import { assetDeviates } from '../../core/properties/deviation';
import type { FontStore } from '../../core/store/FontStore';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import type { EditorState } from '../state';
import { openFontPicker } from '../font/FontPicker';
import { openImagePicker } from '../texture/ImagePicker';
/**
@@ -35,6 +37,8 @@ export class AssetPanel extends Panel {
private readonly state: EditorState,
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
private readonly textures: TextureStore | null = null,
/** The same for typefaces and the Font entry. */
private readonly fonts: FontStore | null = null,
) {
super({ title: 'Assets', padding: 0, gap: 0 });
this.minWidth = 160;
@@ -44,7 +48,7 @@ export class AssetPanel extends Panel {
new Menu(
ASSET_TYPES.map((type) => ({
label: ASSET_TYPE_LABELS[type],
onSelect: () => (type === 'texture' ? this.importTextures() : this.createAsset(type)),
onSelect: () => this.addAsset(type),
})),
).openBelow(add);
this.header.add(add);
@@ -96,9 +100,15 @@ export class AssetPanel extends Panel {
}
/**
* A texture is a picture, so its menu entry offers the ones the server already holds instead of
* creating an empty asset. Uploading a new one is a button inside that dialog.
* A texture is a picture and a font a file, so their menu entries offer what the server already
* holds instead of creating an empty asset. Uploading a new one is a button inside that dialog.
*/
private addAsset(type: AssetType): void {
if (type === 'texture') this.importTextures();
else if (type === 'font') this.importFonts();
else this.createAsset(type);
}
private importTextures(): void {
if (!this.textures) {
this.state.setNotice('Image upload is not available.');
@@ -107,6 +117,14 @@ export class AssetPanel extends Panel {
openImagePicker({ state: this.state, textures: this.textures });
}
private importFonts(): void {
if (!this.fonts) {
this.state.setNotice('Font upload is not available.');
return;
}
openFontPicker({ state: this.state, fonts: this.fonts });
}
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
private activate(id: string): void {
const asset = findAsset(this.state.project, id);
+8 -63
View File
@@ -173,8 +173,11 @@ function selectLast(outcomes: readonly ImportOutcome[], deps: TextureImportDeps)
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
}
/** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */
export function importNotice(outcomes: readonly ImportOutcome[]): string {
/**
* One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie.
* `noun` names what was imported – fonts come through the same batch reporting.
*/
export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image'): string {
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
const reused = outcomes.filter((outcome) => outcome.reused).length;
const failed = outcomes.filter((outcome) => outcome.error !== null);
@@ -185,9 +188,9 @@ export function importNotice(outcomes: readonly ImportOutcome[]): string {
if (!first) {
if (added === 0 && reused > 0)
return reused === 1
? 'That image is already in the project.'
: `Those ${reused} images are already in the project.`;
return `Imported ${added} ${added === 1 ? 'image' : 'images'}.${known}`;
? `That ${noun} is already in the project.`
: `Those ${reused} ${noun}s are already in the project.`;
return `Imported ${added} ${added === 1 ? noun : `${noun}s`}.${known}`;
}
const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : '';
const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`;
@@ -224,61 +227,3 @@ export function pickAndImport(deps: TextureImportDeps): void {
.then((files) => importImages(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
/**
* Wires drop-onto-`target` and paste, and returns the remover for both. Paste is bound to the
* window because the canvas cannot hold focus, and skipped while a DOM text field has it.
*/
export function installTextureImport(target: HTMLElement, deps: TextureImportDeps): () => void {
const onDragOver = (event: DragEvent) => {
if (!hasImageItems(event.dataTransfer)) return;
// Without this the browser navigates away to the dropped file.
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
const files = imageFilesOf(event.dataTransfer);
if (files.length === 0) {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
deps.state.setNotice('Only image files can be dropped into the editor.');
return;
}
event.preventDefault();
run(files, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const files = imageFilesOf(event.clipboardData);
if (files.length === 0) return;
event.preventDefault();
run(files, deps);
};
target.addEventListener('dragover', onDragOver);
target.addEventListener('drop', onDrop);
window.addEventListener('paste', onPaste);
return () => {
target.removeEventListener('dragover', onDragOver);
target.removeEventListener('drop', onDrop);
window.removeEventListener('paste', onPaste);
};
}
function run(files: readonly File[], deps: TextureImportDeps): void {
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
function imageFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
}
/** During a drag the files themselves are not readable yet – only the kinds and types are. */
function hasImageItems(data: DataTransfer | null): boolean {
return [...(data?.items ?? [])].some((item) => item.kind === 'file' && item.type.startsWith('image/'));
}
function isTextFieldFocused(): boolean {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
}
+17 -1
View File
@@ -6,6 +6,7 @@ import { footprintExtent, footprintOf, placementOf, textAnchor } from '../../cor
import { rectsIntersect } from '../../core/properties/camera';
import type { EditorState } from '../state';
import { type Size, Theme, Widget } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary';
import type { DrawItem } from '../../viewport/items';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { type ShaderSource, wornShader } from '../../viewport/shaders';
@@ -57,8 +58,9 @@ export abstract class WorldViewport extends Widget {
state.on('runtime', () => {
if (this.visible && !this.animated) this.redraw();
});
// Unconditional: a picture arriving fires once per image, not per frame.
// Unconditional: a picture or a typeface arriving fires once per file, not per frame.
textureLibrary.onChange(() => this.redraw());
fontLibrary.onChange(() => this.redraw());
}
override measure(): Size {
@@ -247,6 +249,16 @@ export abstract class WorldViewport extends Widget {
syncSpriteMesh(sprite, item, size, textureLibrary, shaders);
}
/**
* The typeface a text is drawn in and how wide that makes it. Both are what a font asset is
* for, and both arrive asynchronously, so a test cannot read them off the model.
*/
textInfo(id: string): { family: string; width: number } | null {
const text = this.texts.get(id);
if (!text?.visible) return null;
return { family: String(text.style.fontFamily), width: Math.round(text.width) };
}
/** Resolution follows the zoom in coarse steps: re-rendering the glyphs per wheel notch is wasteful. */
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
let text = this.texts.get(item.id);
@@ -268,6 +280,10 @@ export abstract class WorldViewport extends Widget {
text.text = textOf(item.props.text ?? '');
const size = Math.max(1, Number(item.props.fontSize ?? 32));
if (text.style.fontSize !== size) text.style.fontSize = size;
// An uploaded face that has not arrived yet reads as `''`; until it does, the editor's own
// typeface stands in, and the library's change event brings the redraw that swaps it.
const family = fontLibrary.family(item.font) || Theme.font;
if (text.style.fontFamily !== family) text.style.fontFamily = family;
const color = Number(item.props.color ?? 0xffffff);
if (text.style.fill !== color) text.style.fill = color;
text.anchor.set(textAnchor(item.props), 0.5);
+63
View File
@@ -0,0 +1,63 @@
import { fontFamilyOf } from '../core/model/font';
const DEFAULT_BASE = '/game-maker/data/fonts';
/**
* Registers uploaded typefaces with the browser, keyed by file name – the image library's twin.
* Nothing is evicted: file names are content addressed, so a face can never go stale. A miss is
* not an error; it starts the load and reports the fallback, and the redraw comes when the face
* lands. Text therefore appears in the default typeface for a frame or two and then reflows.
*/
export class FontLibrary {
private base = DEFAULT_BASE;
private readonly ready = new Set<string>();
private readonly loading = new Set<string>();
/** Never retried, so one broken reference cannot spam the network. */
private readonly failed = new Set<string>();
private readonly listeners = new Set<() => void>();
/** Where the files are served from. Set once at start-up, before anything draws. */
configure(base: string): void {
this.base = base.replace(/\/+$/, '');
}
/**
* The CSS family for a stored file once the browser can draw with it, otherwise `''` while it
* loads or after it failed – the caller then keeps whatever typeface it was using.
*/
family(file: string): string {
if (file === '') return '';
if (this.ready.has(file)) return fontFamilyOf(file);
this.startLoading(file);
return '';
}
/** Runs `fn` whenever a typeface has arrived; returns the unsubscribe. */
onChange(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private startLoading(file: string): void {
if (this.loading.has(file) || this.failed.has(file)) return;
this.loading.add(file);
// `FontFace` rather than a stylesheet rule: it reports when the bytes are actually usable,
// and a family name is asked for by the same content-addressed key that fetched them.
const face = new FontFace(fontFamilyOf(file), `url("${this.base}/${file}")`);
void face.load().then(
(loaded) => {
this.loading.delete(file);
document.fonts.add(loaded);
this.ready.add(file);
for (const listener of this.listeners) listener();
},
(error: unknown) => {
this.loading.delete(file);
this.failed.add(file);
console.error(`[fonts] cannot load ${file}`, error);
},
);
}
}
export const fontLibrary = new FontLibrary();
+6
View File
@@ -5,8 +5,10 @@ import { resolvedHolder } from '../core/model/instance';
import type { Mat } from '../core/model/matrix';
import type { PropertyBag } from '../core/model/Properties';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { fontFileOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture';
import { enabledAlongChain } from '../core/properties/enabled';
import { fontOf } from '../core/properties/fonts';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import { editorTransform } from '../core/properties/transform';
import { animationTime } from '../runtime/systems/AnimationSystem';
@@ -30,6 +32,8 @@ export interface DrawItem {
enabled: boolean;
/** Resolved here because drawing has no access to the project. */
texture: TextureRef | null;
/** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */
font: string;
}
interface DrawList {
@@ -67,6 +71,7 @@ export function collectHolderItems(
parentId: object.parentId,
enabled: true,
texture: asset.type === 'sprite' ? resolveTexture(project, props) : null,
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
});
}
const byId = new Map(items.map((i) => [i.id, i]));
@@ -100,6 +105,7 @@ export function collectWorldItems(world: World): DrawList {
enabled: true,
// Resolved per frame on purpose: a `Properties` node may swap `texture`, an animation runs.
texture: e.type === 'sprite' ? resolveTexture(world.project, e.props, animationTime(e)) : null,
font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '',
}));
return { items, background: world.backgroundColor };
}