diff --git a/.gitignore b/.gitignore index 60fd4b11..666621d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /data/projects/*.json /data/projects/*.tmp /data/textures/ +/data/fonts/ /dist/ /node_modules/ /logs/ diff --git a/README.md b/README.md index 65284d40..026d21bc 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 763 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 775 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/api/index.php b/api/index.php index 35ab456c..9bef4370 100644 --- a/api/index.php +++ b/api/index.php @@ -17,10 +17,15 @@ declare(strict_types=1); * POST ?r=textures raw image bytes → {file,width,height,bytes} * DELETE ?r=textures/{file} → 204 * - * Stored images are served straight from `data/textures/` as static files; the API only - * writes them. + * GET ?r=fonts → [{file,bytes,updatedAt}] + * POST ?r=fonts raw font bytes → {file,bytes} + * DELETE ?r=fonts/{file} → 204 + * + * Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as + * static files; the API only writes them. */ +require __DIR__ . '/lib/FontStore.php'; require __DIR__ . '/lib/Response.php'; require __DIR__ . '/lib/Store.php'; require __DIR__ . '/lib/TextureStore.php'; @@ -30,6 +35,9 @@ const MAX_BODY_BYTES = 8 * 1024 * 1024; /** Upload ceiling for a single image. The editor downscales larger pictures before sending. */ const MAX_TEXTURE_BYTES = 4 * 1024 * 1024; +/** Upload ceiling for a single font. A font cannot be shrunk on the way, so this is a plain limit. */ +const MAX_FONT_BYTES = 4 * 1024 * 1024; + set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { throw new ErrorException($message, 0, $severity, $file, $line); }); @@ -126,6 +134,39 @@ function routeTextures(array $parts, string $method): never Response::error('Method not allowed', 405); } +/** @param list $parts */ +function routeFonts(array $parts, string $method): never +{ + $store = new FontStore(dirname(__DIR__) . '/data/fonts'); + + if (count($parts) === 1) { + if ($method === 'GET') { + Response::json($store->list()); + } + if ($method === 'POST') { + $bytes = Response::rawBody(); + if (strlen($bytes) > MAX_FONT_BYTES) { + Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413); + } + try { + Response::json($store->store($bytes), 201); + } catch (InvalidFontException $e) { + Response::error($e->getMessage(), 422); + } + } + Response::error('Method not allowed', 405); + } + + if ($method === 'DELETE') { + if (!$store->delete($parts[1])) { + Response::error('Font not found', 404); + } + Response::noContent(); + } + + Response::error('Method not allowed', 405); +} + try { if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) { Response::error('Request body too large', 413); @@ -142,6 +183,7 @@ try { match ($parts[0]) { 'projects' => routeProjects($parts, $method), 'textures' => routeTextures($parts, $method), + 'fonts' => routeFonts($parts, $method), default => Response::error('Not found', 404), }; } catch (Throwable $e) { diff --git a/api/lib/FontStore.php b/api/lib/FontStore.php new file mode 100644 index 00000000..41698c99 --- /dev/null +++ b/api/lib/FontStore.php @@ -0,0 +1,120 @@ + 'ttf', + 'true' => 'ttf', + 'OTTO' => 'otf', + 'wOFF' => 'woff', + 'wOF2' => 'woff2', + ]; + + public function __construct(private readonly string $dir) + { + if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { + throw new RuntimeException("Cannot create font directory {$this->dir}"); + } + } + + /** A stored file name, as it appears in a project document. */ + public static function isValidName(string $name): bool + { + return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name); + } + + /** + * Stores the raw bytes of a typeface and returns its record. + * + * The format is read from the file's own signature, never from anything the client says. + * `finfo` is not consulted the way it is for images: it reports fonts inconsistently across + * libmagic versions (`font/sfnt`, `application/vnd.ms-opentype`, plain `application/octet-stream`), + * so a second opinion from it would reject valid files rather than catch bad ones. + * + * @return array{file:string,bytes:int} + * @throws InvalidFontException when the bytes are not a supported font + */ + public function store(string $bytes): array + { + $extension = self::SIGNATURES[substr($bytes, 0, 4)] ?? ''; + if ($extension === '') { + throw new InvalidFontException('Not a font file – use TTF, OTF, WOFF or WOFF2'); + } + // The shortest thing that can still be a font: a table directory and one entry. + if (strlen($bytes) < 28) { + throw new InvalidFontException('Font file is truncated'); + } + + $file = hash('sha256', $bytes) . '.' . $extension; + $path = $this->dir . '/' . $file; + if (!is_file($path)) { + $this->writeFile($path, $bytes); + } + + return ['file' => $file, 'bytes' => strlen($bytes)]; + } + + /** + * Every stored file, newest first – what the font picker offers. Unlike an image there is + * nothing to read out of the header here: the picker shows each face by rendering with it. + * + * @return list + */ + public function list(): array + { + $items = []; + foreach (glob($this->dir . '/*.*') ?: [] as $path) { + $file = basename($path); + if (!self::isValidName($file)) { + continue; + } + $items[] = [ + 'file' => $file, + 'bytes' => (int) filesize($path), + 'updatedAt' => gmdate('c', (int) filemtime($path)), + ]; + } + usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); + return $items; + } + + public function delete(string $file): bool + { + if (!self::isValidName($file)) { + return false; + } + $path = $this->dir . '/' . $file; + return is_file($path) && unlink($path); + } + + /** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */ + private function writeFile(string $path, string $bytes): void + { + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) { + @unlink($tmp); + throw new RuntimeException('Cannot write font file'); + } + @chmod($path, 0664); + } +} + +/** Thrown by FontStore::store when the uploaded bytes are not a usable font. */ +final class InvalidFontException extends RuntimeException +{ +} diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index c7143356..dee6c8e6 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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. diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index c5aacb6e..78d26474 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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; diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 600e7aa1..63289d3a 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { 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 = new Set([ @@ -43,6 +46,7 @@ const NOT_PLACEABLE: ReadonlySet = new Set([ 'animation', 'statemachine', 'shader', + 'font', ]); export function isPlaceableType(type: AssetType): type is PlaceableAssetType { diff --git a/src/core/model/font.ts b/src/core/model/font.ts new file mode 100644 index 00000000..176cbba0 --- /dev/null +++ b/src/core/model/font.ts @@ -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'; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index b2a00d1c..ab4cc33c 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { 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 = { 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 }, { diff --git a/src/core/properties/fonts.ts b/src/core/properties/fonts.ts new file mode 100644 index 00000000..669e5773 --- /dev/null +++ b/src/core/properties/fonts.ts @@ -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]); +} diff --git a/src/core/store/FontStore.ts b/src/core/store/FontStore.ts new file mode 100644 index 00000000..151d9ea9 --- /dev/null +++ b/src/core/store/FontStore.ts @@ -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; + list(): Promise; + delete(file: string): Promise; +} diff --git a/src/core/store/HttpFontStore.ts b/src/core/store/HttpFontStore.ts new file mode 100644 index 00000000..ec99bdb8 --- /dev/null +++ b/src/core/store/HttpFontStore.ts @@ -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 { + return this.request('POST', 'fonts', { bytes, type }); + } + + async list(): Promise { + return this.request('GET', 'fonts'); + } + + async delete(file: string): Promise { + await this.request('DELETE', `fonts/${encodeURIComponent(file)}`); + } + + private async request( + method: string, + route: string, + payload?: { bytes: Blob | ArrayBuffer; type: string }, + ): Promise { + return apiRequest(this.endpoint, method, route, { + headers: payload ? { 'Content-Type': payload.type } : {}, + body: payload?.bytes, + }); + } +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index f1c1b392..eaceebbf 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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 : []; diff --git a/src/editor/App.ts b/src/editor/App.ts index ca8882ca..4dcdb37f 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -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 { 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(); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 83398127..7c0aa8bd 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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(); diff --git a/src/editor/actions.ts b/src/editor/actions.ts index fd70972d..f31d4094 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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)` : '', diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 38035891..ef6ca890 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/fileImport.ts b/src/editor/fileImport.ts new file mode 100644 index 00000000..1f10ea83 --- /dev/null +++ b/src/editor/fileImport.ts @@ -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; +} diff --git a/src/editor/font/FontPicker.ts b/src/editor/font/FontPicker.ts new file mode 100644 index 00000000..7801e38e --- /dev/null +++ b/src/editor/font/FontPicker.ts @@ -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 { + try { + const stored = await this.deps.fonts.list(); + const named = new Map(); + 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; +} diff --git a/src/editor/font/FontPreview.ts b/src/editor/font/FontPreview.ts new file mode 100644 index 00000000..2889af35 --- /dev/null +++ b/src/editor/font/FontPreview.ts @@ -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 }); + } +} diff --git a/src/editor/fontImport.ts b/src/editor/fontImport.ts new file mode 100644 index 00000000..0439b940 --- /dev/null +++ b/src/editor/fontImport.ts @@ -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 { + 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 { + 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 { + 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')); +} diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 865b1fa7..12965722 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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). */ diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index aa8edbab..34fb60e0 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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); diff --git a/src/editor/textureImport.ts b/src/editor/textureImport.ts index 94970b63..e56faddb 100644 --- a/src/editor/textureImport.ts +++ b/src/editor/textureImport.ts @@ -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; -} diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 25fd126a..fecff374 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -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); diff --git a/src/viewport/FontLibrary.ts b/src/viewport/FontLibrary.ts new file mode 100644 index 00000000..c1225d58 --- /dev/null +++ b/src/viewport/FontLibrary.ts @@ -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(); + private readonly loading = new Set(); + /** Never retried, so one broken reference cannot spam the network. */ + private readonly failed = new Set(); + 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(); diff --git a/src/viewport/items.ts b/src/viewport/items.ts index ec422c98..a205d735 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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 }; } diff --git a/tests/core/fonts.test.ts b/tests/core/fonts.test.ts new file mode 100644 index 00000000..a3f291a2 --- /dev/null +++ b/tests/core/fonts.test.ts @@ -0,0 +1,101 @@ +/** + * A font is an asset holding an uploaded typeface, worn by a text object through its `font` + * property the way a sprite wears a texture. Empty, or dangling, means the editor's own typeface. + */ +import { describe, expect, it } from 'vitest'; +import { + buildAsset, + CreateAssetCommand, + CreateFontCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import type { FontAsset } from '../../src/core/model/Asset'; +import { isPlaceableType } from '../../src/core/model/AssetTypes'; +import { fontFamilyOf, fontFileOf, isFontFile } from '../../src/core/model/font'; +import { assetRefChoices } from '../../src/core/properties/assetRef'; +import { FONT_KEY, fontOf } from '../../src/core/properties/fonts'; +import { schemaFor } from '../../src/core/properties/PropertySchema'; +import { deserializeProject } from '../../src/core/store/serialize'; +import { makeProject, roundTrip } from '../helpers'; + +const FILE = `${'a1'.repeat(32)}.ttf`; + +function makeFont(name = 'Serif', file = FILE) { + const fixture = makeProject(); + const command = new CreateFontCommand(name, file); + fixture.stack.execute(command); + return { ...fixture, font: command.asset }; +} + +describe('font files', () => { + it('accepts only a content-addressed name in a supported format', () => { + expect(isFontFile(FILE)).toBe(true); + expect(isFontFile(`${'b3'.repeat(32)}.woff2`)).toBe(true); + expect(isFontFile(`${'a1'.repeat(32)}.png`)).toBe(false); + expect(isFontFile(`${'a1'.repeat(32)}.TTF`)).toBe(false); + expect(isFontFile('DejaVuSerif.ttf')).toBe(false); + expect(isFontFile('')).toBe(false); + }); + + it('names one family per file, and never two for the same bytes', () => { + expect(fontFamilyOf(FILE)).toBe(fontFamilyOf(FILE)); + expect(fontFamilyOf(FILE)).not.toBe(fontFamilyOf(`${'b3'.repeat(32)}.ttf`)); + // A CSS family, so it has to survive being written into a font shorthand unquoted. + expect(fontFamilyOf(FILE)).toMatch(/^[A-Za-z][\w-]*$/); + }); +}); + +describe('the font asset', () => { + it('is not placeable and starts without a file', () => { + expect(isPlaceableType('font')).toBe(false); + expect(buildAsset('font', 'Font')).toMatchObject({ type: 'font', file: '' }); + }); + + it('refuses a file name the server could not have produced', () => { + const fixture = makeProject(); + // The stack lets the error through; the editor turns it into the notice the importer reports. + expect(() => fixture.stack.execute(new CreateFontCommand('Bad', 'DejaVuSerif.ttf'))).toThrow( + 'is not a stored font file', + ); + expect(fixture.project.assets).toHaveLength(0); + }); + + it('survives a round trip and drops a file name it cannot trust', () => { + const f = makeFont(); + expect(roundTrip(f.project)).toEqual(f.project); + + const repaired = deserializeProject({ + name: 'P', + assets: [ + { id: 'f1', type: 'font', name: 'Local', file: '../../etc/passwd' }, + { id: 'f2', type: 'font', name: 'Gone' }, + ], + }); + expect(repaired.assets.map((a) => (a as FontAsset).file)).toEqual(['', '']); + }); +}); + +describe('the Font row on a text', () => { + it('offers font assets and nothing else', () => { + const f = makeFont(); + f.stack.execute(new CreateAssetCommand('text', 'Title')); + const row = schemaFor('text').find((def) => def.key === FONT_KEY); + expect(row).toMatchObject({ kind: 'asset', default: '', assetTypes: ['font'] }); + expect(assetRefChoices(f.project, ['font']).map((a) => a.id)).toEqual([f.font.id]); + }); + + it('resolves to a file, and to none when the id is unset or dangling', () => { + const f = makeFont(); + const text = new CreateAssetCommand('text', 'Title'); + f.stack.execute(text); + expect(fontOf(text.asset.props)).toBe(''); + expect(fontFileOf(f.project, '')).toBe(''); + + f.stack.execute(new SetAssetPropertyCommand(text.asset.id, FONT_KEY, f.font.id)); + expect(fontFileOf(f.project, fontOf(text.asset.props))).toBe(FILE); + + expect(fontFileOf(f.project, 'no-such-asset')).toBe(''); + // A text asset is not a font asset: an id pointing at the wrong type wears nothing. + expect(fontFileOf(f.project, text.asset.id)).toBe(''); + }); +}); diff --git a/tests/editor/font-import.test.ts b/tests/editor/font-import.test.ts new file mode 100644 index 00000000..3b2a90d6 --- /dev/null +++ b/tests/editor/font-import.test.ts @@ -0,0 +1,101 @@ +/** + * Importing a typeface: the same two ways a picture arrives – picked from what the server already + * holds, or uploaded from a dropped file – with the same content-addressed reuse. + */ +import { describe, expect, it, vi } from 'vitest'; +import type { FontAsset } from '../../src/core/model/Asset'; +import { createEmptyProject } from '../../src/core/model/Project'; +import type { FontStore } from '../../src/core/store/FontStore'; +import { importFonts, importStoredFont, isFontFileName } from '../../src/editor/fontImport'; +import { EditorState } from '../../src/editor/state'; +import { importNotice } from '../../src/editor/textureImport'; + +const FILE = `${'a'.repeat(64)}.ttf`; +const font = { file: FILE, bytes: 380_660 }; + +function setup(): EditorState { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + return state; +} + +describe('isFontFileName', () => { + it('goes by the extension, because a dropped font often has no MIME type at all', () => { + expect(isFontFileName('DejaVuSerif.ttf')).toBe(true); + expect(isFontFileName('Inter.WOFF2')).toBe(true); + expect(isFontFileName('cover.png')).toBe(false); + expect(isFontFileName('ttf')).toBe(false); + }); +}); + +describe('importStoredFont', () => { + const fonts = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as FontStore; + + it('makes an asset out of a typeface the server already holds', () => { + const state = setup(); + const outcome = importStoredFont(font, { state, fonts }); + + const asset = state.project.assets.find((a): a is FontAsset => a.type === 'font'); + expect(asset).toMatchObject({ file: FILE, name: 'Font' }); + expect(outcome).toMatchObject({ assetId: asset?.id, error: null, reused: false }); + expect(state.selection).toEqual({ kind: 'asset', assetId: asset?.id }); + expect(state.notice).toBe('Added the font to the project.'); + state.undo(); + expect(state.project.assets.filter((a) => a.type === 'font')).toEqual([]); + }); + + it('reuses the asset that already names the file instead of making a second one', () => { + const state = setup(); + const first = importStoredFont(font, { state, fonts }); + const again = importStoredFont(font, { state, fonts }); + + expect(again.assetId).toBe(first.assetId); + expect(again.reused).toBe(true); + expect(state.project.assets.filter((a) => a.type === 'font')).toHaveLength(1); + expect(state.notice).toBe('That font is already in the project.'); + }); +}); + +describe('importFonts', () => { + it('uploads the file untouched and names the asset after it', async () => { + const state = setup(); + const upload = vi.fn(() => Promise.resolve(font)); + const fonts = { upload, list: vi.fn(), delete: vi.fn() } as unknown as FontStore; + const file = new File([new Uint8Array(8)], 'DejaVuSerif.ttf', { type: 'font/ttf' }); + + const [outcome] = await importFonts([file], { state, fonts }); + + // The blob goes up as it came in: a typeface cannot be re-encoded smaller on the way. + expect(upload).toHaveBeenCalledWith(file, 'font/ttf'); + expect(outcome).toMatchObject({ error: null, reused: false }); + expect(state.project.assets.find((a): a is FontAsset => a.type === 'font')).toMatchObject({ + file: FILE, + name: 'DejaVuSerif', + }); + expect(state.notice).toBe('Imported 1 font.'); + }); + + it('reports a file that is not a font without asking the server about it', async () => { + const state = setup(); + const upload = vi.fn(() => Promise.resolve(font)); + const fonts = { upload, list: vi.fn(), delete: vi.fn() } as unknown as FontStore; + + const [outcome] = await importFonts([new File([new Uint8Array(8)], 'cover.png')], { + state, + fonts, + }); + + expect(upload).not.toHaveBeenCalled(); + expect(outcome?.assetId).toBeNull(); + expect(state.notice).toContain('Could not import "cover.png"'); + }); +}); + +describe('importNotice', () => { + it('says what was imported, so fonts do not report as images', () => { + const done = { name: 'a.ttf', assetId: 'id', error: null, reused: false }; + expect(importNotice([done], 'font')).toBe('Imported 1 font.'); + expect(importNotice([done, { ...done, name: 'b.ttf' }], 'font')).toBe('Imported 2 fonts.'); + expect(importNotice([{ ...done, reused: true }], 'font')).toBe('That font is already in the project.'); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 6145f73c..8a2b2edb 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -208,7 +208,7 @@ export async function shader(t) { project.assets.push(sprite); const scene = __gm.state.activeScene; const cam = scene.objects.find((o) => o.id === 'o3').position; - scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x, y: cam.y, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); + scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x + 180, y: cam.y + 100, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); __gm.state.select(null)`); await sleep(600); // The pixels are the scene view's; the tab is still the shader's. @@ -218,11 +218,24 @@ export async function shader(t) { const box = await json("__gm.objectRect('oshade')"); check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box)); if (!box) return; - // Off centre: the camera symbol sits on the very same spot, and symbols draw above objects. - const centre = () => - json(`__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`); + const sample = (fx, fy) => + json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`); + // Off centre: a symbol may sit on the sprite's middle, and symbols draw above objects. + const centre = () => sample(0.3, 0.3); const base = await centre(); check('with no shader the sprite is its plain colour', colourDistance(base, BASE) < 12, base); + + // Every later check reads pixels at its own spot on this sprite, so nothing may be drawn over it. + // The step parks the sprite away from the camera for that reason: symbols draw above objects, and + // by this point the earlier steps have left containers and colliders around the origin. Without + // this guard their outlines show up as a wrong colour in whichever check happens to sample them. + const dirty = []; + for (let fx = -0.4; fx <= 0.401; fx += 0.1) + for (let fy = -0.4; fy <= 0.401; fy += 0.1) { + const seen = await sample(fx, fy); + if (colourDistance(seen, BASE) >= 12) dirty.push(`${fx.toFixed(1)},${fy.toFixed(1)}:${seen}`); + } + check('nothing is drawn over the sprite the pixel checks read', dirty.length === 0, dirty.join(' ')); // The Shader row's dropdown reads "None"; it is the last such text in the inspector (Parent and // Texture say it above). The label itself is no anchor: the tab button says "Shader" too. const shaderField = await locate('None', true); diff --git a/tools/e2e/30-font.mjs b/tools/e2e/30-font.mjs new file mode 100644 index 00000000..e9eada31 --- /dev/null +++ b/tools/e2e/30-font.mjs @@ -0,0 +1,219 @@ +import { LANES } from './lib/scaffold.mjs'; + +/** + * Font assets (#74): uploaded like a picture – picked from what the server holds through "+ → Font", + * or dropped onto the editor – and worn by a text object through its Font row. No font means the + * editor's own typeface, which is also what a reference that no longer resolves falls back to. + * + * The seed puts two DejaVu faces on the server without making assets of them: this step makes them. + * Everything lives in its own lane (`__gm.lookAt`), because symbols draw above objects and the + * origin is crowded by what the earlier steps left behind. + */ +const LANE = LANES.font; +const TEXT_ASSET = 'ft-text'; +const TEXT_OBJECT = 'ft-obj'; +/** The two files the seed uploads, told apart by their exact size rather than by their name. */ +const SERIF_BYTES = 380660; +const MONO_BYTES = 343140; + +export async function font(t) { + const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot } = t; + const { select } = t; + const assetsOfType = (type) => + json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)}) + .map((a) => ({ id: a.id, name: a.name, file: a.file }))`); + const info = () => json(`__gm.textInfo(${JSON.stringify(TEXT_OBJECT)})`); + /** The overlay is drawn last, so a menu entry wins over a row of the asset list saying the same. */ + const clickLast = async (text) => { + const p = await locate(text, true); + await click(p.x, p.y); + await sleep(200); + }; + /** A face arrives over the network, so what it changes is awaited rather than slept for. */ + const waitFor = async (read, done, tries = 20) => { + let value = await read(); + for (let i = 0; i < tries && !done(value); i++) { + await sleep(150); + value = await read(); + } + return value; + }; + + await press('Escape'); + await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); + await evaluate('__gm.state.select(null)'); + await evaluate("__gm.showTab('scene')"); + await sleep(200); + + // 30a. What the server holds, straight from the API: the two files the seed put there. + // `evaluate`, not `json`: the latter stringifies the promise itself rather than what it yields. + const stored = await evaluate(`fetch('/game-maker/api/index.php?r=fonts').then((r) => r.json())`); + const serif = stored.find((item) => item.bytes === SERIF_BYTES); + const mono = stored.find((item) => item.bytes === MONO_BYTES); + check( + 'the server holds the two seeded typefaces, named by their content', + Boolean(serif && mono) && /^[a-f0-9]{64}\.ttf$/.test(serif.file), + JSON.stringify({ serif: serif?.file, mono: mono?.file, count: stored.length }), + ); + if (!serif || !mono) return; + + // 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture. + await evaluate('__gm.console.clear()'); + await clickText('+'); + await sleep(200); + await clickLast('Font'); + await sleep(500); + const picker = await json('__gm.fontPicker()'); + check( + 'the + menu opens the font picker on what the server holds, none of it in the project yet', + Boolean(picker) && picker.files.includes(serif.file) && picker.used.length === 0, + JSON.stringify(picker), + ); + await shot('30-font-picker'); + // A row is a container holding its background, not a Graphics with children: Pixi v8 warns + // about the latter, and the warning is the only sign that the rows are built the wrong way. + const logged = await json('__gm.console.lines()'); + check( + 'building the rows draws no complaint from Pixi', + !logged.some((line) => /Deprecation/i.test(JSON.stringify(line))), + JSON.stringify(logged.slice(0, 3)), + ); + const row = await json(`__gm.fontPickerRow(${JSON.stringify(serif.file)})`); + await click(row.x, row.y); + await sleep(200); + await clickText('Use font'); + await sleep(400); + const fonts = await assetsOfType('font'); + check( + 'picking a row makes a font asset of that file, and nothing else', + fonts.length === 1 && fonts[0].file === serif.file && fonts[0].name === 'Font', + JSON.stringify(fonts), + ); + if (fonts.length !== 1) return; + const fontId = fonts[0].id; + // Renamed away from "Font": the inspector row is called that too, and a lookup by text would + // find the asset's row in the panel instead of the row being tested. + await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).name = 'Bookish'`); + + // 30c. A text in its own lane, drawn in the editor's own typeface while it wears no font. + await mutate(` + const project = __gm.state.project; + project.assets.push({ id: ${JSON.stringify(TEXT_ASSET)}, type: 'text', name: 'Title', + props: { enabled: true, scale: 1, rotation: 0, mirrorX: false, mirrorY: false, + text: 'Handgloves', font: '', fontSize: 48, color: 0xffffff, align: 'center' } }); + __gm.state.activeScene.objects.push({ id: ${JSON.stringify(TEXT_OBJECT)}, assetId: ${JSON.stringify(TEXT_ASSET)}, + name: '', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 }, + overrides: {}, parentId: '', instanceEdits: {} }); + __gm.lookAt(0, ${LANE});`); + await sleep(400); + const plain = await info(); + check( + 'a text wearing no font is drawn in the editor’s own typeface', + Boolean(plain) && !plain.family.startsWith('gm-font-') && plain.width > 0, + JSON.stringify(plain), + ); + if (!plain) return; + + // 30d. Wearing the font, chosen the way a user chooses it: the Font row of the text object. + await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT }); + await sleep(300); + // The label is no anchor on its own: the assets panel has a row per font. The dropdown of the + // Font row is the last "None" in the window – Parent says it above, nothing says it below. + const fontRow = await json("__gm.inspectorField('Font', true)"); + const none = await locate('None', true); + check( + 'a text has a Font row, and it reads None', + Boolean(fontRow) && Math.abs(none.y - fontRow.y) < 12, + JSON.stringify({ fontRow, none }), + ); + await click(fontRow.x, fontRow.y); + await sleep(250); + await clickLast('Bookish (font)'); + await sleep(400); + const chosen = await json( + `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font`, + ); + const worn = await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-'))); + check( + 'picking it in the Font row redraws the text in that face, and it reflows', + chosen === fontId && Boolean(worn) && worn.width !== plain.width, + JSON.stringify({ chosen, plain, worn }), + ); + await shot('30-font-scene'); + // The asset's own inspector is where a font is looked at; it has nothing else to show. + await select({ kind: 'asset', assetId: fontId }); + await sleep(300); + await shot('30-font-asset'); + + // 30e. The game draws through the same path, so the face is in play as well. The camera frames + // the origin, so the text has to leave its lane for as long as the game runs. + const place = (expression) => + mutate(` + const scene = __gm.state.activeScene; + const cam = scene.objects.find((o) => o.id === 'o3').position; + const text = scene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}); + Object.assign(text.position, ${expression});`); + await place('{ x: cam.x, y: cam.y }'); + await play(); + await sleep(600); + const played = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`); + check( + 'the running game draws the text in the same face', + Boolean(played?.family.startsWith('gm-font-')) && played.family === worn?.family, + JSON.stringify(played), + ); + await stop(); + await place(`{ x: 0, y: ${LANE} }`); + await sleep(300); + + // 30f. An id that no longer resolves is no font at all – the text falls back rather than vanishing. + await mutate( + `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font = 'no-such-asset'`, + ); + const dangling = await waitFor(info, (value) => !value?.family.startsWith('gm-font-')); + check( + 'a dangling font id falls back to the editor’s own typeface, at the width from before', + dangling?.family === plain.family && dangling?.width === plain.width, + JSON.stringify(dangling), + ); + + // 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file. + const dropped = await evaluate(` + (async () => { + const bytes = await fetch('/game-maker/data/fonts/${mono.file}').then((r) => r.arrayBuffer()); + const file = new File([bytes], 'DejaVuSansMono.ttf', { type: 'font/ttf' }); + const data = new DataTransfer(); + data.items.add(file); + const canvas = document.querySelector('canvas'); + canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true })); + return file.size; + })()`); + await waitFor( + () => assetsOfType('font'), + (list) => list.length === 2, + ); + const both = await assetsOfType('font'); + check( + 'a dropped typeface is uploaded and becomes an asset named after the file', + dropped === MONO_BYTES && + both.length === 2 && + both.some((a) => a.file === mono.file && a.name === 'DejaVuSansMono'), + JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }), + ); + + // 30h. The step takes back everything it made, so the steps after it see the fixture again. + await mutate(` + const gone = new Set([${JSON.stringify(TEXT_ASSET)}, ...${JSON.stringify(both.map((a) => a.id))}]); + const project = __gm.state.project; + project.assets = project.assets.filter((a) => !gone.has(a.id)); + __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter( + (o) => o.id !== ${JSON.stringify(TEXT_OBJECT)}); + __gm.state.select(null); + __gm.lookAt(0, 0);`); + await sleep(300); + check( + 'the step leaves nothing of its own behind', + (await assetsOfType('font')).length === 0 && (await info()) === null, + JSON.stringify(await assetsOfType('font')), + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index eb2a54d7..962bf8b1 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -32,6 +32,7 @@ import { stateMachine } from './26-state-machine.mjs'; import { consolePanel } from './27-console.mjs'; import { shader } from './28-shader.mjs'; import { transform } from './29-transform.mjs'; +import { font } from './30-font.mjs'; export const STEPS = [ hierarchy, @@ -63,4 +64,5 @@ export const STEPS = [ consolePanel, shader, transform, + font, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index a3d7af2d..99809693 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -17,6 +17,7 @@ export const LANES = { stateMachine: -700, console: -1100, transform: -1500, + font: -1900, }; /** diff --git a/tools/lib/projects.mjs b/tools/lib/projects.mjs index b91db410..6aa6006f 100644 --- a/tools/lib/projects.mjs +++ b/tools/lib/projects.mjs @@ -73,6 +73,38 @@ export async function uploadTexture(baseUrl, filePath) { return { file: result.file, width: result.width, height: result.height }; } +const FONT_MIME = { + ttf: 'font/ttf', + otf: 'font/otf', + woff: 'font/woff', + woff2: 'font/woff2', +}; + +/** + * Uploads a typeface and returns `{file}` for a font asset. Content-addressed like an image, so + * re-seeding the same file is a no-op (`data/fonts/` is git-ignored). + */ +export async function uploadFont(baseUrl, filePath) { + const bytes = await readFile(filePath); + const type = FONT_MIME[extname(filePath).slice(1).toLowerCase()]; + if (!type) { + console.error(`unsupported font type: ${filePath}`); + process.exit(1); + } + const res = await fetch(`${baseUrl}api/index.php?r=fonts`, { + method: 'POST', + headers: { 'Content-Type': type }, + body: bytes, + signal: AbortSignal.timeout(20_000), + }); + const result = await res.json(); + if (!res.ok) { + console.error('font upload failed', res.status, result); + process.exit(1); + } + return { file: result.file }; +} + /** Overwrites a project, keeping the name it already has on the server. */ export async function writeProject(projectId, baseUrl, project) { const url = `${baseUrl}api/index.php?r=projects/${projectId}`; diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index ff4172e6..8b58065d 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -7,11 +7,21 @@ * Usage: node tools/seed-e2e.mjs [projectId] [baseUrl] */ import { edge, node, port, sceneObject, variable } from './lib/builders.mjs'; -import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; +import { PROJECTS, cliArgs, uploadFont, writeProject } from './lib/projects.mjs'; import { adjustColorsShader } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture); +/** + * Two typefaces for the font step to pick and to drop. They are put on the server but not made + * into assets: the step is about the picker and the drop making them. The files come from the + * system rather than the repo – the CI image installs the same DejaVu package the host has, + * because the checks measure text widths (see tools/ci/Dockerfile). + */ +const SYSTEM_FONTS = '/usr/share/fonts/truetype/dejavu'; +await uploadFont(baseUrl, `${SYSTEM_FONTS}/DejaVuSerif.ttf`); +await uploadFont(baseUrl, `${SYSTEM_FONTS}/DejaVuSansMono.ttf`); + /** * "Mover" in five nodes: every frame, move the owner along the normalized arrow-key axis, * scaled by the `speed` variable per second (On update's dt feeds the dt input). "Collide with