// @ts-check /** * The file chooser: Kokkoja's own dialog for choosing an image file * ("Image") and for choosing where to export a picture of the page * ("Export"). The browser's file chooser cannot be told where to begin * and cannot choose a place to write to; this one begins where the * notebooks are, remembers where it was last used, and reaches the whole * file system through the local program. */ import { listFiles, readFile } from './files.js'; /** Where the chooser was last used, for each of its two purposes. */ const DIRECTORY_KEYS = { image: 'kokkoja_image_directory', export: 'kokkoja_export_directory' }; /** The longer side of the small pictures of image files, in pixels. */ const SMALL_PICTURE_SIDE = 240; /** No small picture is made of a file larger than this. */ const SMALL_PICTURE_BYTES = 32 << 20; /** * @param {string} id * @returns {HTMLElement} */ function element(id) { return /** @type {HTMLElement} */ (document.getElementById(id)); } export class FileChooser { constructor() { this.dialog = /** @type {HTMLDialogElement} */ (element('file_chooser')); this.directoryField = /** @type {HTMLInputElement} */ (element('files_directory')); this.nameField = /** @type {HTMLInputElement} */ (element('files_name')); /** @type {'image' | 'export'} */ this.purpose = 'image'; /** @type {import('./files.js').DirectoryListing | null} */ this.listing = null; /** Counts listings, so that work begun for an earlier one is dropped. */ this.showing = 0; /** @type {((result: any) => void) | null} Ends the present use. */ this.finish = null; /** @type {((directory: string, name: string, replace: boolean) => Promise) | null} */ this.save = null; /** The name that a second press of the button may replace. */ this.mayReplace = ''; element('files_close').addEventListener('click', () => this.done(null)); // Escape closes the dialog by itself. The browser reports a close a // moment after it happens; if the chooser is open again by then, the // report is about the earlier use and must not end this one. this.dialog.addEventListener('close', () => { if (!this.dialog.open) this.done(null); }); element('files_up').addEventListener('click', () => { if (this.listing?.parent) this.show(this.listing.parent); }); this.directoryField.addEventListener('keydown', (event) => { if (event.key !== 'Enter') return; event.preventDefault(); this.show(this.directoryField.value.trim()); }); this.nameField.addEventListener('input', () => this.forgetReplacing()); this.nameField.addEventListener('keydown', (event) => { if (event.key !== 'Enter') return; event.preventDefault(); this.saveHere(); }); element('files_save_here').addEventListener('click', () => this.saveHere()); } /** * Let the user choose an image file. * @returns {Promise} The file, or null if none was chosen. */ chooseImage() { return this.open('image', 'Image'); } /** * Let the user choose a directory and a name, and save there. * @param {string} title * @param {string} name The name to suggest. * @param {(directory: string, name: string, replace: boolean) => Promise} save * Writes the file and returns its full path, or null if a file of * that name exists and `replace` is false. * @returns {Promise} The full path written, or null. */ chooseWhereToSave(title, name, save) { const result = this.open('export', title); this.save = save; this.nameField.value = name; return result; } /** * @param {'image' | 'export'} purpose * @param {string} title * @returns {Promise} */ open(purpose, title) { if (this.dialog.open) return Promise.resolve(null); this.finish?.(null); this.purpose = purpose; element('files_title').textContent = title; element('files_save').hidden = purpose !== 'export'; element('files_directories').replaceChildren(); element('files_entries').replaceChildren(); this.dialog.showModal(); const result = new Promise((resolve) => { this.finish = resolve; }); // Where it was last used; if that is gone, where the notebooks are. this.show(localStorage.getItem(DIRECTORY_KEYS[purpose]) ?? '').then((shown) => { if (!shown && this.finish) this.show(''); }); return result; } /** @param {any} result */ done(result) { const finish = this.finish; this.finish = null; this.save = null; this.showing += 1; if (this.dialog.open) this.dialog.close(); finish?.(result); } /** @param {string} message */ say(message) { element('files_message').textContent = message; } forgetReplacing() { this.mayReplace = ''; element('files_save_here').textContent = 'Export'; } /** * Show a directory. * @param {string} directory A full path, or '' for where the notebooks are. * @returns {Promise} Whether it could be shown. */ async show(directory) { const showing = this.showing += 1; let listing; try { listing = await listFiles(directory); } catch (error) { if (showing === this.showing) this.say(error instanceof Error ? error.message : String(error)); return false; } if (showing !== this.showing) return true; this.listing = listing; this.directoryField.value = listing.directory; /** @type {HTMLButtonElement} */ (element('files_up')).disabled = listing.parent === null; localStorage.setItem(DIRECTORY_KEYS[this.purpose], listing.directory); this.forgetReplacing(); // The directories go down the left, one to a line, and the files // beside them (Andy, 2026-10-05), so that neither pushes the other out // of view. const entries = element('files_entries'); entries.replaceChildren(); const directories = element('files_directories'); directories.replaceChildren(); for (const one of listing.directories) { const button = document.createElement('button'); button.type = 'button'; button.textContent = one.name; button.addEventListener('click', () => this.show(one.path)); directories.append(button); } const files = this.purpose === 'image' ? listing.files : listing.files.filter((file) => file.name.toLowerCase().endsWith('.png')); if (this.purpose === 'image') this.showImages(files, entries, showing); else this.showExisting(files, entries); this.say(this.purpose === 'image' && files.length === 0 ? 'No image files here.' : ''); return true; } /** * The image files of a directory, each with a small picture, made one * after another as the files come into view. * @param {import('./files.js').DirectoryListing['files']} files * @param {HTMLElement} entries * @param {number} showing */ showImages(files, entries, showing) { const grid = document.createElement('div'); grid.className = 'files_images'; /** @type {Map} */ const waiting = new Map(); /** @type {Element[]} Cards in view, awaiting their pictures. */ const queue = []; let drawing = false; const drawNext = async () => { if (drawing) return; drawing = true; for (let card = queue.shift(); card && showing === this.showing; card = queue.shift()) { const entry = waiting.get(card); waiting.delete(card); if (!entry) continue; try { const bitmap = await createImageBitmap(await readFile(entry.path)); const scale = Math.min(1, SMALL_PICTURE_SIDE / Math.max(bitmap.width, bitmap.height)); entry.picture.width = Math.max(1, Math.round(bitmap.width * scale)); entry.picture.height = Math.max(1, Math.round(bitmap.height * scale)); entry.picture.getContext('2d')?.drawImage(bitmap, 0, 0, entry.picture.width, entry.picture.height); bitmap.close(); } catch { // Not an image the browser can read: the card keeps its name only. } } drawing = false; }; const inView = new IntersectionObserver((changes) => { for (const change of changes) { if (!change.isIntersecting || !waiting.has(change.target)) continue; inView.unobserve(change.target); queue.push(change.target); } drawNext(); }, { root: entries }); for (const file of files) { const card = document.createElement('button'); card.type = 'button'; card.className = 'file_card'; const picture = document.createElement('canvas'); picture.width = picture.height = 1; const name = document.createElement('span'); name.textContent = file.name; card.append(picture, name); card.addEventListener('click', async () => { try { this.done(await readFile(file.path)); } catch (error) { this.say(error instanceof Error ? error.message : String(error)); } }); grid.append(card); if (file.size <= SMALL_PICTURE_BYTES) { waiting.set(card, { path: file.path, picture }); inView.observe(card); } } if (files.length) entries.append(grid); } /** * The PNG files already in a directory, by name: choosing one takes its * name, to replace it. * @param {import('./files.js').DirectoryListing['files']} files * @param {HTMLElement} entries */ showExisting(files, entries) { const list = document.createElement('div'); list.className = 'files_existing'; for (const file of files) { const button = document.createElement('button'); button.type = 'button'; button.textContent = file.name; button.addEventListener('click', () => { this.nameField.value = file.name; this.forgetReplacing(); }); list.append(button); } if (files.length) entries.append(list); } /** Save into the directory shown, under the name in the name field. */ async saveHere() { const save = this.save; const directory = this.listing?.directory; let name = this.nameField.value.trim(); if (!save || !directory || !name) return; if (!name.toLowerCase().endsWith('.png')) name += '.png'; this.nameField.value = name; try { const saved = await save(directory, name, this.mayReplace === name); if (saved !== null) { this.done(saved); return; } // No system dialog: a second press of the button replaces the file. this.mayReplace = name; element('files_save_here').textContent = 'Replace'; this.say(`${name} exists here.`); } catch (error) { this.say(error instanceof Error ? error.message : String(error)); } } }