Files
feuerwerk/src/editor/fontImport.ts
T
DeveloperandClaude Opus 5 99c5d2ad78 Uploads carry the name the file came with (#83)
An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.

The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:32:45 +02:00

107 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, assetNameForFile, 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', file.name);
return adopt(uploaded, assetNameForFile(file.name, 'Font'), 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 {
// Named after the file it was uploaded as, like a dropped one; "Font" when the server knows none.
const outcome = adopt(font, assetNameForFile(font.name, 'Font'), font.name || 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;
}
/** 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'));
}