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 { 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', 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 { 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')); }