control/DragAndDrop.tstypescript
import { Control, type IControlParams } from "./Control";
import { Vector } from "../layer/Vector";
import { GeoTIFFLayer } from "../layer/GeoTIFFLayer";
import { GeoTiffTerrain } from "../terrain/GeoTiffTerrain";
import { GeoTiffTypeDialog, type GeoTiffType } from "./GeoTiffTypeDialog";
import { Entity } from "../entity/Entity";
import { Object3d } from "../Object3d";
import { Gltf } from "../utils/gltf/gltfParser";
import { Vec2 } from "../math/Vec2";
import { EntityEditor } from "./entityEditor/EntityEditor";
import type { EmptyTerrain } from "../terrain/EmptyTerrain";
import type { LonLat } from "../LonLat";

export interface IDragAndDropParams extends IControlParams {
    entityEditor?: EntityEditor;
}

const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg"];
const TIFF_EXT = ["tif", "tiff"];

const UNSAFE_NAME_CHARS = /[<>&"'`]/g;
const MAX_BILLBOARD_SIZE = 256;

function getExt(file: File): string {
    const i = file.name.lastIndexOf(".");
    return i !== -1 ? file.name.slice(i + 1).toLowerCase() : "";
}

function getBaseName(file: File): string {
    const i = file.name.lastIndexOf(".");
    return i !== -1 ? file.name.slice(0, i) : file.name;
}

function loadImage(src: string): Promise<HTMLImageElement> {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => resolve(img);
        img.onerror = () => reject(new Error(`Unable to load image '${src}'`));
        img.src = src;
    });
}

/**
 * Adds dropped files on the globe: glb/obj models and images are placed in the drop point,
 * GeoTIFF rasters are placed by their georeference. A GeoTIFF that is not an image for certain may hold
 * heights, its tags do not tell that, so the control asks whether to add it as a terrain or as a layer.
 */
export class DragAndDrop extends Control {
    public geoObjectsLayer: Vector;
    public markersLayer: Vector;
    public tiffLayers: GeoTIFFLayer[];
    public tiffTerrains: GeoTiffTerrain[];

    protected _entityEditor: EntityEditor | null;

    /** Terrain to bring back when the control is removed */
    protected _baseTerrain: EmptyTerrain | null;

    protected _tiffDialog: GeoTiffTypeDialog | null;

    /** Dropped files are added in parallel, their questions go one by one */
    protected _tiffQuestions: Promise<unknown>;

    constructor(options: IDragAndDropParams = {}) {
        super({
            name: "DragAndDrop",
            autoActivate: true,
            ...options
        });

        this.geoObjectsLayer = new Vector("__geoObjects__");

        this.markersLayer = new Vector("__markers__");

        this.tiffLayers = [];

        this.tiffTerrains = [];

        this._entityEditor = options.entityEditor || null;

        this._baseTerrain = null;

        this._tiffDialog = null;

        this._tiffQuestions = Promise.resolve();
    }

    public override oninit() {
        this.planet!.addLayers([this.geoObjectsLayer, this.markersLayer]);
    }

    public override onremove() {
        this.geoObjectsLayer.remove();
        this.markersLayer.remove();
        for (let i = 0; i < this.tiffLayers.length; i++) {
            this.tiffLayers[i].remove();
        }
        this.tiffLayers = [];
        this._tiffDialog?.close();
        this._removeTiffTerrains();
    }

    protected _removeTiffTerrains() {
        const planet = this.planet!;

        if (this._baseTerrain && this._isTiffTerrain(planet.terrain)) {
            planet.setTerrain(this._baseTerrain);
        }

        this.tiffTerrains = this.tiffTerrains.filter((terrain) => {
            if (terrain === planet.terrain) {
                return true;
            }
            planet.removeTerrain(terrain);
            terrain.reader.destroy();
            return false;
        });

        this._baseTerrain = null;
    }

    protected _isTiffTerrain(terrain: EmptyTerrain | null): boolean {
        return this.tiffTerrains.some((t) => t === terrain);
    }

    public override onactivate() {
        const el = this.renderer!.getInnerContainer();
        el.addEventListener("dragover", this._onDragOver);
        el.addEventListener("drop", this._onDrop);
    }

    public override ondeactivate() {
        const el = this.renderer!.getInnerContainer();
        el.removeEventListener("dragover", this._onDragOver);
        el.removeEventListener("drop", this._onDrop);
    }

    protected _onDragOver = (e: DragEvent) => {
        e.preventDefault();
        if (e.dataTransfer) {
            e.dataTransfer.dropEffect = "copy";
        }
    };

    protected _onDrop = (e: DragEvent) => {
        e.preventDefault();
        if (!e.dataTransfer || !e.dataTransfer.files.length) return;
        const lonLat = this._getLonLatFromEvent(e);
        this.addFiles(Array.from(e.dataTransfer.files), lonLat);
    };

    protected _getLonLatFromEvent(e: DragEvent): LonLat | undefined {
        const h = this.renderer!.handler;
        const rect = h.canvas!.getBoundingClientRect();
        const px = new Vec2((e.clientX - rect.left) * h.pixelRatio, (e.clientY - rect.top) * h.pixelRatio);
        return this.planet!.getLonLatFromPixelTerrain(px);
    }

    /**
     * Adds files on the globe. Mtl file goes along with the obj file in the same list.
     */
    public addFiles(files: File[], lonLat?: LonLat) {
        const mtlFile = files.find((f) => getExt(f) === "mtl") || null;
        for (let i = 0; i < files.length; i++) {
            this.addFile(files[i], lonLat, mtlFile).catch((err) => {
                console.warn(`DragAndDrop: unable to add '${files[i].name}'.`, err);
            });
        }
    }

    public async addFile(file: File, lonLat?: LonLat, mtlFile?: File | null): Promise<void> {
        const ext = getExt(file);
        if (ext === "glb" || ext === "gltf") {
            lonLat && (await this._addGltf(file, lonLat));
        } else if (ext === "obj") {
            lonLat && (await this._addObj(file, lonLat, mtlFile));
        } else if (IMAGE_EXT.includes(ext)) {
            lonLat && (await this._addBillboard(file, lonLat));
        } else if (TIFF_EXT.includes(ext)) {
            await this._addTiff(file);
        }
    }

    protected async _addGltf(file: File, lonLat: LonLat) {
        const url = URL.createObjectURL(file);
        try {
            const gltf = getExt(file) === "glb" ? await Gltf.loadGlb(url) : await Gltf.loadGltf(url);
            const entity = new Entity({
                name: getBaseName(file),
                lonlat: lonLat
            });
            entity.appendChildren(gltf.toEntities(), true);
            this.geoObjectsLayer.add(entity);
            this._selectEntity(entity);
        } finally {
            URL.revokeObjectURL(url);
        }
    }

    protected async _addObj(file: File, lonLat: LonLat, mtlFile?: File | null) {
        const name = getBaseName(file);
        const objects = await Object3d.readFileObj(file, mtlFile);
        const entity = new Entity({
            name,
            lonlat: lonLat
        });
        for (let i = 0; i < objects.length; i++) {
            entity.appendChild(
                new Entity({
                    relativePosition: true,
                    geoObject: {
                        tag: `${name}:${i.toString()}`,
                        object3d: objects[i]
                    }
                })
            );
        }
        this.geoObjectsLayer.add(entity);
        this._selectEntity(entity);
    }

    protected _selectEntity(entity: Entity) {
        if (!this._entityEditor) {
            const control = this.renderer?.controls.EntityEditor;
            if (control instanceof EntityEditor) {
                this._entityEditor = control;
            }
        }
        this._entityEditor?.selectEntity(entity);
    }

    protected async _addBillboard(file: File, lonLat: LonLat) {
        const image = await loadImage(URL.createObjectURL(file));
        const scale = Math.min(1.0, MAX_BILLBOARD_SIZE / Math.max(image.width, image.height));
        this.markersLayer.add(
            new Entity({
                name: getBaseName(file),
                lonlat: lonLat,
                billboard: {
                    image,
                    width: Math.round(image.width * scale),
                    height: Math.round(image.height * scale)
                }
            })
        );
    }

    protected _createTiffLayer(file: File): GeoTIFFLayer {
        return new GeoTIFFLayer(`__tif__${file.name}`, { src: file });
    }

    protected _createTiffTerrain(file: File): GeoTiffTerrain {
        return new GeoTiffTerrain(getBaseName(file).replace(UNSAFE_NAME_CHARS, ""), { src: file });
    }

    protected _askTiffType(file: File): Promise<GeoTiffType | null> {
        const answer = this._tiffQuestions.then(() => this._showTiffDialog(file));
        this._tiffQuestions = answer;
        return answer;
    }

    protected _showTiffDialog(file: File): Promise<GeoTiffType | null> {
        return new Promise((resolve) => {
            if (!this.renderer) {
                resolve(null);
                return;
            }

            const dialog = new GeoTiffTypeDialog({
                fileName: file.name,
                onClose: (type) => {
                    this._tiffDialog = null;
                    resolve(type);
                }
            });

            const container = this.renderer.getUIContainer();
            dialog.appendTo(container);
            dialog.setPosition(
                Math.max(0, (container.clientWidth - dialog.el!.offsetWidth) / 2),
                Math.max(0, (container.clientHeight - dialog.el!.offsetHeight) / 2)
            );

            this._tiffDialog = dialog;
        });
    }

    protected async _addTiff(file: File) {
        const layer = this._createTiffLayer(file);

        let isImagery: boolean;

        try {
            isImagery = (await layer.whenReady()).isImagery;
        } catch (err) {
            layer.remove();
            throw err;
        }

        const type = isImagery ? "layer" : await this._askTiffType(file);

        if (type === "layer") {
            this.tiffLayers.push(layer);
            this.planet!.addLayer(layer);
            layer.flyExtent();
            return;
        }

        layer.remove();

        if (type === "terrain") {
            await this._addTiffTerrain(file);
        }
    }

    protected async _addTiffTerrain(file: File) {
        const terrain = this._createTiffTerrain(file);

        await terrain.whenReady();

        if (!this.renderer) {
            terrain.reader.destroy();
            return;
        }

        const planet = this.planet!;

        if (!this._isTiffTerrain(planet.terrain)) {
            this._baseTerrain = planet.terrain;
        }

        this.tiffTerrains.push(terrain);
        planet.setTerrain(terrain);
        terrain.flyExtent();
    }
}