/** * Drag & drop local-file path (PLAN P2-1, P2-2). * * Captures HTML5 DnD events + click-to-browse on the #dropzone element, * validates the picked file is a supported model, and forwards valid files to * viewer-core's loadLocalFile. createObjectURL/revoke lifecycle is owned by * the viewer (per the locked Blob URL pattern in SKILL.md); this module only * validates + hands the File off. */ import { extOf } from '../viewer/modelLoader'; /** Viewer surface this module depends on (viewer-core implements loadLocalFile). */ export interface ViewerHandle { loadLocalFile: (file: File, sidecars?: File[]) => void; } /** Options for initDropzone. */ export interface DropzoneOpts { viewer: ViewerHandle; dropzone: HTMLElement; fileInput: HTMLInputElement; /** Optional error sink; defaults to console.warn. */ onError?: (msg: string) => void; } /** Accept any supported model extension by name (case-insensitive). */ function isValidModel(file: File): boolean { return extOf(file.name) !== null; } /** * Wire up drag/drop + click-to-browse on the given dropzone. * Idempotent-ish: attaches listeners; caller should call once per element. */ export function initDropzone(opts: DropzoneOpts): void { const { dropzone, fileInput, viewer } = opts; const onError = opts.onError ?? ((msg: string) => console.warn(msg)); // Accept multiple files at once: the first supported model is the primary, // the rest (e.g. a .mtl beside an .obj) ride along as sidecars. const handleFiles = (files: File[]): void => { if (!files.length) return; const model = files.find(isValidModel); if (!model) { onError("Unsupported file (use .glb .gltf .obj .fbx .dae .ifc)"); return; } viewer.loadLocalFile(model, files.filter((f) => f !== model)); }; // --- Drag & drop --- // preventDefault on dragover is MANDATORY or the browser navigates to the file. dropzone.addEventListener("dragover", (e: DragEvent) => { e.preventDefault(); dropzone.classList.add("drag"); }); dropzone.addEventListener("dragenter", (e: DragEvent) => { e.preventDefault(); dropzone.classList.add("drag"); }); dropzone.addEventListener("dragleave", () => { dropzone.classList.remove("drag"); }); dropzone.addEventListener("drop", (e: DragEvent) => { e.preventDefault(); dropzone.classList.remove("drag"); handleFiles(Array.from(e.dataTransfer?.files ?? [])); }); // --- Click to browse (accessibility) --- dropzone.addEventListener("click", () => { fileInput.click(); }); fileInput.addEventListener("change", () => { handleFiles(Array.from(fileInput.files ?? [])); // reset so picking the same file twice fires `change` again fileInput.value = ""; }); }