System > Appearance gains a Thumbnails surface: format (WebP, JPEG XL, JPEG), quality and size limit, saved to the server with the settings revision. JPEG XL is offered only when the server has an encoder, else the option is disabled with the server's reason (D130); a note says when this browser cannot show JPEG XL. Saving a new format converts nothing: the row then says previews keep their old format. Thumbnail maintenance gains the Convert outdated scope and names the server's settings instead of its own quality and size fields (D131). Overview's thumbnail row reads N missing, M outdated, K failed and offers Convert outdated while previews are outdated. The workspace decodes a tiny JPEG XL once: where it cannot, cards, the Library and the inspector cover load the original for a .jxl preview, and any preview that fails to load falls back to the original. The viewer and downloads keep using the original (regression test). README explains enabling JPEG XL; TESTING.md records D130 and D131.
119 lines
6.3 KiB
JavaScript
119 lines
6.3 KiB
JavaScript
/**
|
||
* System › Appearance › Thumbnails (#317): the concept README keeps "Thumbnail quality, size limit" as Appearance
|
||
* settings; they are the server's (`thumbnail_format`, `thumbnail_quality`, `thumbnail_edge`, saved with the settings
|
||
* revision through the one Save row), so every preview writer follows them. One `section.surface` of the concept's
|
||
* `.setting-row`s (system-controls.mjs), after "Browsing & display":
|
||
*
|
||
* - "Thumbnail format": WebP (default), JPEG XL, JPEG. D130: JPEG XL is not in the concept; it is offered only when
|
||
* the server found an encoder (`thumbnail_encoders.jxl`), else the option is disabled and the row says why, in the
|
||
* server's words. A row note also says when this browser cannot show JPEG XL thumbnails (thumbnail-support.mjs):
|
||
* such a browser shows the originals instead.
|
||
* - "Thumbnail quality" (1–100) and "Thumbnail size limit" (128–2048 px); a value outside is refused like Appearance's
|
||
* timeout (no draft, the control highlighted).
|
||
* - "Convert outdated thumbnails" opens Thumbnail maintenance on its Convert outdated scope; converting is never
|
||
* automatic. After a save that changed the format, its line says existing previews keep their old format until
|
||
* converted.
|
||
*
|
||
* Host rows stay disabled until the server settings were read, like Appearance's.
|
||
*/
|
||
import { settingRow, operationRow } from './system-controls.mjs';
|
||
import { detectJxlSupport } from './thumbnail-support.mjs';
|
||
import { openThumbnailReview } from './thumbnail-review.mjs';
|
||
|
||
export const FORMAT_OPTIONS = Object.freeze([['webp', 'WebP'], ['jxl', 'JPEG XL'], ['jpeg', 'JPEG']]);
|
||
export const QUALITY_LIMIT = Object.freeze({ min: 1, max: 100 });
|
||
export const EDGE_LIMIT = Object.freeze({ min: 128, max: 2048 });
|
||
export const BROWSER_NO_JXL = 'This browser can’t show JPEG XL thumbnails; it shows the originals instead.';
|
||
export const CONVERT_DESCRIPTION = 'Replace previews in an older format from their originals, then remove the old files.';
|
||
export const FORMAT_SAVED = 'Format saved. Existing previews keep their old format until you convert them.';
|
||
|
||
/** A whole number within `{min, max}` from a field's text, else null. */
|
||
export function wholeNumber(text, { min, max }) {
|
||
const value = String(text).trim();
|
||
if (!/^\d+$/.test(value)) return null;
|
||
const number = Number(value);
|
||
return number >= min && number <= max ? number : null;
|
||
}
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
const ROWS = Object.freeze([
|
||
['thumbnail-format', 'Thumbnail format', 'WebP is small and fast everywhere. JPEG XL is smaller still where your browser shows it.',
|
||
'thumbnail_format', { type: 'select', options: FORMAT_OPTIONS }],
|
||
['thumbnail-quality', 'Thumbnail quality', 'From 1 to 100. 80 keeps previews sharp and small.', 'thumbnail_quality',
|
||
{ type: 'number', ...QUALITY_LIMIT }],
|
||
['thumbnail-edge', 'Thumbnail size limit', 'Longest edge in pixels, 128 to 2048. Rebuild all previews to apply a new size.',
|
||
'thumbnail_edge', { type: 'number', ...EDGE_LIMIT }],
|
||
]);
|
||
const LIMITS = Object.freeze({ thumbnail_quality: QUALITY_LIMIT, thumbnail_edge: EDGE_LIMIT });
|
||
|
||
/**
|
||
* Mount the Thumbnails surface; returns it. `support()` resolves whether this browser shows JPEG XL, `openReview`
|
||
* opens Thumbnail maintenance (`{mode, trigger}`).
|
||
*/
|
||
export function mountThumbnailSettings(container, { scope, drafts, api, activity, support = detectJxlSupport,
|
||
openReview = options => openThumbnailReview({ api, store: activity, scope, ...options }) }) {
|
||
const surface = el('section', undefined, 'surface');
|
||
surface.append(el('h2', 'Thumbnails'), el('p', 'Previews in the grid and the inspector cover. Originals are never changed.'));
|
||
const controls = [];
|
||
for (const [id, label, description, key, control] of ROWS) {
|
||
let input;
|
||
const onChange = value => {
|
||
if (LIMITS[key]) {
|
||
value = wholeNumber(input.value, LIMITS[key]);
|
||
if (value === null) { drafts.reject('host', key); return; }
|
||
}
|
||
drafts.set('host', key, value);
|
||
};
|
||
const row = settingRow({ id, label, description, control, scope, onChange });
|
||
input = row.children[1];
|
||
input.dataset.key = key;
|
||
surface.append(row);
|
||
controls.push({ input, key, text: row.children[0] });
|
||
}
|
||
const operation = operationRow({ title: 'Convert outdated thumbnails', description: CONVERT_DESCRIPTION,
|
||
label: 'Convert outdated', scope, onClick: trigger => openReview({ mode: 'convert', trigger }) });
|
||
surface.append(operation);
|
||
const operationText = operation.children[0].children[1];
|
||
|
||
let browserJxl;
|
||
let savedFormat;
|
||
const format = controls[0];
|
||
const notes = [];
|
||
const repaint = () => {
|
||
const encoders = drafts.get('host', 'thumbnail_encoders');
|
||
for (const { input, key } of controls) {
|
||
const value = drafts.get('host', key);
|
||
input.disabled = value === undefined;
|
||
if (value !== undefined && (document.activeElement !== input || key === 'thumbnail_format')) input.value = String(value);
|
||
}
|
||
const jxl = Array.from(format.input.children).find(option => option.value === 'jxl');
|
||
const unavailable = encoders?.jxl?.available === false;
|
||
if (jxl) jxl.disabled = unavailable;
|
||
for (const note of notes.splice(0)) note.remove();
|
||
const lines = [unavailable ? encoders.jxl.reason : null, browserJxl === false ? BROWSER_NO_JXL : null];
|
||
for (const line of lines.filter(Boolean)) {
|
||
const note = el('p', line, 'small');
|
||
format.text.append(note);
|
||
notes.push(note);
|
||
}
|
||
// A format save (not the first read) leaves the existing previews outdated: say so, never convert.
|
||
const saved = drafts.saved('host').thumbnail_format;
|
||
if (savedFormat !== undefined && saved !== undefined && saved !== savedFormat) operationText.textContent = FORMAT_SAVED;
|
||
if (saved !== undefined) savedFormat = saved;
|
||
};
|
||
scope.cleanup(drafts.subscribe(repaint));
|
||
repaint();
|
||
Promise.resolve(support()).then(value => {
|
||
if (scope.signal.aborted) return;
|
||
browserJxl = value;
|
||
repaint();
|
||
}, () => {});
|
||
return surface;
|
||
}
|