Assembled from the development repository by tools/make_release.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
313 lines
11 KiB
JavaScript
313 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' };
|
|
|
|
/** Whether an image wider than the page is shrunk to fit it: 'no' if not. */
|
|
const SHRINK_KEY = 'kokkoja_image_shrink';
|
|
|
|
/** 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'));
|
|
this.shrinkBox = /** @type {HTMLInputElement} */ (element('files_shrink'));
|
|
this.shrinkBox.checked = localStorage.getItem(SHRINK_KEY) !== 'no';
|
|
this.shrinkBox.addEventListener('change', () => {
|
|
localStorage.setItem(SHRINK_KEY, this.shrinkBox.checked ? 'yes' : 'no');
|
|
});
|
|
/** @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');
|
|
}
|
|
|
|
/**
|
|
* @returns {boolean} Whether the user wants an image wider than the page
|
|
* shrunk to fit it (the other choice keeps its size and widens the page).
|
|
*/
|
|
shrinksWideImages() {
|
|
return this.shrinkBox.checked;
|
|
}
|
|
|
|
/**
|
|
* 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_shrink_line').hidden = purpose !== 'image';
|
|
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));
|
|
}
|
|
}
|
|
}
|