Every viewer gets a button before the close button that switches between Centered (today's framed dialog) and Full screen (the whole browser window, edge to edge); it names the view it switches to. In full screen the details stay toggleable. System > Appearance gains a Viewer surface with "Open viewer in: Centered / Full screen" and the moved "Collapse viewer details by default"; the choice is a browser display preference (viewerOpenMode). The button changes only the current view and hides at 700px and below, where the viewer is already full-bleed. Recorded as D156.
113 lines
6.0 KiB
JavaScript
113 lines
6.0 KiB
JavaScript
/**
|
|
* Browser display defaults (#249 System S3 Task 7). The workspace keeps them in the legacy gallery's own
|
|
* `gallerySettings` object (Q3, "One display configuration"), so the gallery, the workspace and the viewer read one
|
|
* configuration: the new `workspaceLayout` and `workspaceDensity` beside the legacy keys they share (metadata, paths,
|
|
* card details, viewer details, image quality and the video defaults). The legacy paging keys (`defaultLimit`,
|
|
* `gridColumns`, `infiniteScroll`, …) belong to the legacy gallery and are never read here (README continuous-browsing
|
|
* ruling).
|
|
*
|
|
* - `readPreferences(getStorage, normalize)` → `{values, persisted}`: the workspace keys, normalized by `gallery-settings.js`;
|
|
* `persisted` is false when this browser refused its storage.
|
|
* - `writePreferences(dirty, getStorage, {baseline})` → `{persisted, conflicts}`: re-reads the stored object and writes
|
|
* back only the dirty keys, every other key byte-for-byte as stored. A write the browser refused (denied storage, a
|
|
* full quota) leaves the stored bytes as they were and keeps the change for this tab only, so every reader in this
|
|
* tab still sees it; the next write storage accepts stores those changes too. `conflicts` lists the dirty keys another page changed since `baseline` was read; the explicit
|
|
* save still wins.
|
|
* - `subscribePreferences(win, onChange, getStorage)` → unsubscribe: `onChange(readPreferences())` after another page
|
|
* changed the settings (a `storage` event).
|
|
* - `reconcilePreferences({baseline, drafts, next})` → `{conflicts}`: which drafts such a change overtook.
|
|
*
|
|
* Every storage access is in try/catch: a private window, blocked site data or a malformed value never breaks a page.
|
|
*/
|
|
export const PREFERENCES_KEY = 'gallerySettings';
|
|
export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata',
|
|
'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault', 'viewerOpenMode']);
|
|
|
|
// The changes this tab could not store, overlaid on every read until storage accepts them.
|
|
const tabOnly = new Map();
|
|
const defaultStorage = () => globalThis.localStorage;
|
|
const normalizer = () => globalThis.GallerySettings.normalizeGallerySettings;
|
|
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
/** Forget what this tab kept without storage (tests, and nothing else). */
|
|
export function resetTabPreferences() {
|
|
tabOnly.clear();
|
|
}
|
|
|
|
const pick = settings => Object.fromEntries(WORKSPACE_PREFERENCE_KEYS.map(key => [key, settings[key]]));
|
|
/** The gallery's defaults for the workspace keys. */
|
|
export function preferenceDefaults() {
|
|
return pick(normalizer()(null));
|
|
}
|
|
|
|
/** The stored object (`{}` when absent or malformed), or null when storage is unavailable. */
|
|
function storedObject(storage) {
|
|
let text;
|
|
try { text = storage.getItem(PREFERENCES_KEY); } catch { return null; }
|
|
try {
|
|
const parsed = text ? JSON.parse(text) : null;
|
|
return isObject(parsed) ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function storageOf(getStorage) {
|
|
try { return getStorage() ?? null; } catch { return null; }
|
|
}
|
|
|
|
/** `normalize` is gallery-settings.js's `normalizeGallerySettings` (the page's `window.GallerySettings` by default). */
|
|
export function readPreferences(getStorage = defaultStorage, normalize = normalizer()) {
|
|
const storage = storageOf(getStorage);
|
|
const stored = storage ? storedObject(storage) : null;
|
|
const values = pick(normalize({ ...(stored ?? {}), ...Object.fromEntries(tabOnly) }));
|
|
return { values, persisted: stored !== null };
|
|
}
|
|
|
|
/** A change is only a workspace key with a value the gallery's normalizer keeps as given. */
|
|
function validate(dirty) {
|
|
if (!isObject(dirty)) throw new TypeError('Preferences need an object of changes');
|
|
for (const [key, value] of Object.entries(dirty)) {
|
|
if (!WORKSPACE_PREFERENCE_KEYS.includes(key)) throw new TypeError(`Not a workspace display preference: ${key}`);
|
|
if (normalizer()({ [key]: value })[key] !== value) throw new TypeError(`Invalid value for ${key}`);
|
|
}
|
|
}
|
|
|
|
export function writePreferences(dirty, getStorage = defaultStorage, { baseline = null } = {}) {
|
|
validate(dirty);
|
|
const keep = () => { for (const [key, value] of Object.entries(dirty)) tabOnly.set(key, value); };
|
|
const storage = storageOf(getStorage);
|
|
const stored = storage ? storedObject(storage) : null;
|
|
if (stored === null) { keep(); return { persisted: false, conflicts: [] }; }
|
|
const current = pick(normalizer()(stored));
|
|
const conflicts = baseline ? Object.keys(dirty).filter(key => Object.hasOwn(baseline, key) && current[key] !== baseline[key]) : [];
|
|
try {
|
|
// The changes this tab could not store before are stored with this one, so "Saved" is true for all of them.
|
|
storage.setItem(PREFERENCES_KEY, JSON.stringify({ ...stored, ...Object.fromEntries(tabOnly), ...dirty }));
|
|
} catch {
|
|
keep();
|
|
return { persisted: false, conflicts };
|
|
}
|
|
tabOnly.clear();
|
|
return { persisted: true, conflicts };
|
|
}
|
|
|
|
/** The current values for a display consumer, or null on a page without gallery-settings.js (a unit test). */
|
|
export function displayPreferences(getStorage = defaultStorage) {
|
|
try { return readPreferences(getStorage).values; } catch { return null; }
|
|
}
|
|
|
|
export function subscribePreferences(win, onChange, getStorage = defaultStorage) {
|
|
const listener = event => {
|
|
if (event.key !== null && event.key !== PREFERENCES_KEY) return;
|
|
if (event.storageArea && event.storageArea !== storageOf(getStorage)) return;
|
|
onChange(readPreferences(getStorage));
|
|
};
|
|
win.addEventListener('storage', listener);
|
|
return () => win.removeEventListener('storage', listener);
|
|
}
|
|
|
|
export function reconcilePreferences({ baseline, drafts, next }) {
|
|
return { conflicts: Object.keys(drafts).filter(key => next[key] !== baseline[key] && next[key] !== drafts[key]) };
|
|
}
|