Files
feuerwerk/src/editor/fileImport.ts
T
rootandClaude Opus 5 86b5363a01
game-maker / suite (push) Successful in 9m10s
Fonts: a new asset type, picked from the server or dropped in, worn by text objects (#74)
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.

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

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

80 lines
3.2 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 { 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;
}