game-maker / suite (push) Successful in 9m10s
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
3.2 KiB
TypeScript
80 lines
3.2 KiB
TypeScript
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;
|
||
}
|