Like Appearance's other server rows, the Thumbnails rows show 'Server settings could not be read.' while the read failed, instead of only staying disabled. Firefox exposed the gap: its JPEG XL note arrived first and changed the row notes the page workflow counted.
121 lines
6.6 KiB
JavaScript
121 lines
6.6 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, and say "Server settings could not
|
||
* be read." while a read failed.
|
||
*/
|
||
import { settingRow, operationRow } from './system-controls.mjs';
|
||
import { detectJxlSupport } from './thumbnail-support.mjs';
|
||
import { openThumbnailReview } from './thumbnail-review.mjs';
|
||
import { HOST_UNAVAILABLE } from './system-appearance.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 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;
|
||
const failed = drafts.hostState?.() === 'failed';
|
||
for (const row of controls) {
|
||
for (const note of row.notes ?? []) note.remove();
|
||
const lines = [failed && drafts.get('host', row.key) === undefined ? HOST_UNAVAILABLE : null];
|
||
if (row === format) lines.push(unavailable ? encoders.jxl.reason : null, browserJxl === false ? BROWSER_NO_JXL : null);
|
||
row.notes = lines.filter(Boolean).map(line => el('p', line, 'small'));
|
||
row.text.append(...row.notes);
|
||
}
|
||
// 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;
|
||
}
|