Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
95c80d04c9
39 files changed
+9905
No files matched your search
@@ -0,0 +1,312 @@
|
||||
// @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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user