Files
kokkoja-rel/client/js/file_chooser.js
T

296 lines
11 KiB
JavaScript

// @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<string | null>) | 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<Blob | null>} 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<string | null>} save
* Writes the file and returns its full path, or null if a file of
* that name exists and `replace` is false.
* @returns {Promise<string | null>} 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<any>}
*/
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<boolean>} 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<Element, {path: string, picture: HTMLCanvasElement}>} */
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));
}
}
}