Source: filetarget.js

/**
 * This class installs global event handlers for dragging and dropping a file or
 * clicking to upload a file. The relevant callback gets called, based on the
 * element that the file was dropped on or selected through. This allows us to
 * swap out and rerender whole sections of the DOM without having to reinstall a
 * bunch of event handlers each time. This nicely decouples the render logic
 * from the drag event management logic.
 *
 * Also, a class `dragging` will be applied to the element when the user drags a
 * file over it. The name of this class can be changed below if necessary.
 *
 * @example
 * import ft from "./filetarget.js";
 *
 * ft.setEventListener("div#file-thing", (file, element) = {
 *   console.log(file);      // Will give the name, binary contents, and text contents of the file
 *   console.log(element);   // Will give back the div#file-thing element that was clicked/dropped on
 * }, {
 *   startIn: "documents",                  // Optional directions for the file picker
 *   types: [{
 *     description: 'My very special file type',
 *     accept: {
 *       'text/plain': ['.extension'],
 *     },
 *   }],
 * });
 * ```
 *
 * @module
 */

const DRAG_CLASS = "dragging";

/**
 * @typedef {Object} LoadOptions
 * @property {FileTypes[]} types - Which file types are supported
 * @property {("desktop"|"document"|"downloads"|"music"|"pictures"|"videos")}
 * startIn - Where the file picker opens
 */

/**
 * @typedef {Object} LoadedFile
 * @property {string} name - The name of the file
 * @property {ArrayBuffer} binaryContents - The binary contents of the file
 * @property {string} textContents - The text contents of the file
 */

/**
 * Handle a global file drop or select event
 * @callback fileEventListener
 * @param {LoadedFile} file - The file that was uploaded
 * @param {HTMLElement} element - The element that it was uploaded through
 */

if (!window.fileHandlers) {
  document.addEventListener("dragover", (e) => wrapHandler(e, dragover));
  document.addEventListener("dragleave", (e) => wrapHandler(e, dragleave));
  document.addEventListener("drop", (e) => wrapHandler(e, drop));
  document.addEventListener("click", (e) => wrapHandler(e, select));
  window.fileHandlers = {};
}

/**
 * Set a global file target listener
 * @param {string} selector - CSS selector of the element(s) to trigger on
 * @param {fileEventListener} handler - The function to call when a file is uploaded on `selector`
 * @param {LoadOptions} options - Options for the file picker
 */
export function setEventListener(selector, handler, options = {}) {
  window.fileHandlers[selector] = {
    func: handler,
    options: options,
  };
}

function wrapHandler(e, func) {
  for (const selector in window.fileHandlers) {
    const element = e.target.closest(selector);
    if (element) {
      e.stopPropagation();
      e.preventDefault();
      return func(element, window.fileHandlers[selector], e);
    }
  }
}

function dragover(element, _, e) {
  e.dataTransfer.dropEffect = "copy";
  element.classList.add(DRAG_CLASS);
}

function dragleave(element) {
  element.classList.remove(DRAG_CLASS);
}

async function drop(element, handler, e) {
  element.classList.remove(DRAG_CLASS);
  const file = await createLoadedFile(e.dataTransfer.files[0]);
  handler.func(file, element);
}

async function select(element, handler, e) {
  element.classList.remove(DRAG_CLASS);

  if ("showOpenFilePicker" in window) {
    const [fileHandle] = await window.showOpenFilePicker(handler.options);
    const file = await fileHandle.getFile();
    handler.func(
      {
        name: file.name,
        binaryContents: await file.arrayBuffer(),
        textContents: await file.text(),
      },
      element
    );
    return;
  }

  const input = document.createElement("input");
  input.type = "file";
  input.addEventListener("change", async (c) => {
    if (c.target.files.length != 1) return;
    const file = await createLoadedFile(c.target.files[0]);
    handler.func(file, element);
  });
  input.click();
}

async function createLoadedFile(file) {
  return {
    name: file.name,
    binaryContents: await readFileBinary(file),
    textContents: await readFileText(file),
  };
}

function readFileBinary(file) {
  return new Promise((resolve, reject) => {
    var reader = new FileReader();
    reader.addEventListener("load", (e) => resolve(e.target.result));
    reader.addEventListener("error", reject);
    reader.readAsArrayBuffer(file);
  });
}

function readFileText(file) {
  return new Promise((resolve, reject) => {
    var reader = new FileReader();
    reader.addEventListener("load", (e) => resolve(e.target.result));
    reader.addEventListener("error", reject);
    reader.readAsText(file);
  });
}