import { type FontImportDeps, importFonts, isFontFileName } from './fontImport'; import { importSounds, isSoundFileName, type SoundImportDeps } from './soundImport'; import { importImages, type TextureImportDeps } from './textureImport'; import { openVideoPicker } from './video/VideoPicker'; import { isVideoFileName, uploadVideos, type VideoImportDeps } from './videoImport'; export type FileImportDeps = TextureImportDeps & FontImportDeps & SoundImportDeps & VideoImportDeps; /** * 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, typefaces, sounds and videos 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 files = sortFiles(event.dataTransfer); if (files.none) { if (!event.dataTransfer?.files.length) return; event.preventDefault(); deps.state.setNotice('Only image, font, sound and video files can be dropped into the editor.'); return; } event.preventDefault(); run(files, deps); }; const onPaste = (event: ClipboardEvent) => { if (isTextFieldFocused()) return; const files = sortFiles(event.clipboardData); if (files.none) 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); }; } interface SortedFiles { images: File[]; fonts: File[]; sounds: File[]; videos: File[]; none: boolean; } function sortFiles(data: DataTransfer | null): SortedFiles { const all = [...(data?.files ?? [])]; const images = all.filter((file) => file.type.startsWith('image/')); const fonts = all.filter((file) => isFontFileName(file.name)); const sounds = all.filter((file) => isSoundFileName(file.name)); const videos = all.filter((file) => isVideoFileName(file.name)); return { images, fonts, sounds, videos, none: images.length + fonts.length + sounds.length + videos.length === 0, }; } /** One kind after the other, so a mixed drop ends on one notice instead of three racing ones. */ function run(files: SortedFiles, deps: FileImportDeps): void { const work = async () => { if (files.images.length) await importImages(files.images, deps); if (files.fonts.length) await importFonts(files.fonts, deps); if (files.sounds.length) await importSounds(files.sounds, deps); // A video is converted first (#402): the picker opens to show how far, and adopts it when done. if (files.videos.length) await uploadVideos(files.videos, deps, (pending) => openVideoPicker(deps, pending)); }; void work().catch((error: unknown) => deps.state.setNotice(String(error), 'error')); } /** * 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; }