feat(viewer): full-screen toggle beside the close button, and a System default (#450)
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.
This commit is contained in:
@@ -21,7 +21,10 @@ const paths = Object.freeze({
|
||||
"tagImage": "M3 4h8l9 9-7 7L3 10V4ZM7 8h.01M18 2v5M15.5 4.5h5",
|
||||
"folder": "M3 5h7l2 3h9v12H3z",
|
||||
"history": "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2",
|
||||
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5"
|
||||
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5",
|
||||
// #450 (D156): the viewer's Full screen / Centered switch.
|
||||
"maximize": "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5",
|
||||
"minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"
|
||||
});
|
||||
|
||||
/** Create an approved decorative outline icon without injecting markup. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import {createViewerSession} from './viewer-state.mjs';
|
||||
import {rootedFileAdapter} from './inspection-state.mjs';
|
||||
import {mountFileInspector} from './file-inspector.mjs';
|
||||
import {displayPreferences, readPreferences} from './preferences.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
|
||||
let viewerId = 0;
|
||||
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
|
||||
@@ -51,7 +52,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
const heading = element('div'), title = element('h2');title.id = 'viewer-title';
|
||||
heading.append(title,element('p','muted small','One viewer · image, prompts, generation data, workflow'));
|
||||
const close = createButton('×');close.id = 'viewer-close';close.setAttribute('aria-label','Close image viewer');close.autofocus = true;
|
||||
header.append(heading,close);
|
||||
// #450 (D156): the view switch before ✕ names the view it switches to; the stored default picks the first view.
|
||||
let fullscreen = displayPreferences()?.viewerOpenMode === 'fullscreen';
|
||||
const mode = createButton('','icon-btn');mode.id = 'viewer-mode';
|
||||
header.append(heading,mode,close);
|
||||
const body = element('div','viewer-layout'), left = element('div','viewer-left');
|
||||
const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0;
|
||||
stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview');
|
||||
@@ -75,27 +79,36 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
controls.append(download);
|
||||
const next = button('Next media','Next →',() => session.navigate(1));
|
||||
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. D155
|
||||
// (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing.
|
||||
// (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing. D156 (#450):
|
||||
// the toggle is also there while the view is full screen.
|
||||
const collapsible = displayPreferences()?.sidebarCollapsedByDefault === true, collapsed = collapsible && detailsMode !== 'shown';
|
||||
let detailsToggle = null;
|
||||
if (collapsible) {
|
||||
detailsToggle = createButton(collapsed ? 'Show details' : 'Hide details');detailsToggle.setAttribute('aria-expanded',String(!collapsed));
|
||||
detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle);
|
||||
}
|
||||
const detailsToggle = createButton('');detailsToggle.setAttribute('aria-controls','viewer-details');
|
||||
const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`;
|
||||
position.setAttribute('role','status');
|
||||
const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`;
|
||||
const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
|
||||
controls.append(position,reason,slideshowReason);left.append(stage,controls);
|
||||
const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
|
||||
if (detailsToggle) {
|
||||
details.hidden = collapsed;
|
||||
local.listen(detailsToggle,'click',() => {
|
||||
details.hidden = !details.hidden;
|
||||
detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details';
|
||||
detailsToggle.setAttribute('aria-expanded',String(!details.hidden));
|
||||
});
|
||||
details.hidden = collapsed;
|
||||
function paintToggle() {
|
||||
detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details';
|
||||
detailsToggle.setAttribute('aria-expanded',String(!details.hidden));
|
||||
}
|
||||
/** The toggle is in the toolbar only in full screen or with the collapse setting; without both the details show. */
|
||||
function syncToggle() {
|
||||
if (collapsible || fullscreen) { if (!detailsToggle.isConnected) controls.insertBefore(detailsToggle,position); }
|
||||
else { detailsToggle.remove();details.hidden = false; }
|
||||
paintToggle();
|
||||
}
|
||||
function paintMode() {
|
||||
if (fullscreen) dialog.classList.add('viewer-fullscreen'); else dialog.classList.remove('viewer-fullscreen');
|
||||
const label = fullscreen ? 'Centered view' : 'Full screen view';
|
||||
mode.setAttribute('aria-label',label);mode.title = label;
|
||||
mode.replaceChildren(createWorkspaceIcon(fullscreen ? 'minimize' : 'maximize'));
|
||||
}
|
||||
local.listen(detailsToggle,'click',() => {details.hidden = !details.hidden;paintToggle();});
|
||||
local.listen(mode,'click',() => {fullscreen = !fullscreen;paintMode();syncToggle();});
|
||||
paintMode();syncToggle();
|
||||
body.append(left,details);dialog.append(header,body);container.append(dialog);
|
||||
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
|
||||
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
*/
|
||||
export const PREFERENCES_KEY = 'gallerySettings';
|
||||
export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata',
|
||||
'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault']);
|
||||
'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();
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
* (`showImageInfo`), "Collapse viewer details by default" (`sidebarCollapsedByDefault`) and the operation "Reset
|
||||
* display defaults", which drafts the gallery's defaults for these browser rows and touches no other key.
|
||||
*
|
||||
* - #317: a second surface, "Thumbnails", holds the server's thumbnail format, quality and size limit
|
||||
* - #450 (D156, amends D97): a "Viewer" surface after it holds "Open viewer in" (Centered→`centered`, Full
|
||||
* screen→`fullscreen`, `viewerOpenMode`) and the moved "Collapse viewer details by default"; "Reset display defaults"
|
||||
* covers both surfaces' browser rows.
|
||||
* - #317: a further surface, "Thumbnails", holds the server's thumbnail format, quality and size limit
|
||||
* (system-thumbnails.mjs).
|
||||
*
|
||||
* Controls follow the store: a value read later (the host settings, another tab's change) repaints them. A host row
|
||||
@@ -50,14 +53,20 @@ export const APPEARANCE_ROWS = Object.freeze([
|
||||
{ type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']] }],
|
||||
['timeout', 'Node search auto-hide (seconds)', 'Node search behavior only. Use 0 to keep results visible.', 'host', 'result_timeout',
|
||||
{ type: 'number' }],
|
||||
// D97: the legacy gallery's card and viewer settings.
|
||||
// D97: the legacy gallery's card setting (its viewer setting moved to VIEWER_ROWS, D156).
|
||||
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', 'browser', 'showImageInfo', BOOLEAN],
|
||||
]);
|
||||
|
||||
/** #450 (D156): the "Viewer" surface's rows, in the same shape. */
|
||||
export const VIEWER_ROWS = Object.freeze([
|
||||
['viewer-mode', 'Open viewer in', 'Full screen fills the browser window; Centered is the framed viewer.', 'browser', 'viewerOpenMode',
|
||||
{ type: 'select', options: [['centered', 'Centered'], ['fullscreen', 'Full screen']] }],
|
||||
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', 'browser',
|
||||
'sidebarCollapsedByDefault', BOOLEAN],
|
||||
]);
|
||||
|
||||
/** The browser keys "Reset display defaults" restores (D97). */
|
||||
export const DISPLAY_KEYS = Object.freeze(APPEARANCE_ROWS.filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4]));
|
||||
/** The browser keys "Reset display defaults" restores (D97), on both surfaces. */
|
||||
export const DISPLAY_KEYS = Object.freeze([...APPEARANCE_ROWS, ...VIEWER_ROWS].filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4]));
|
||||
|
||||
/** A whole number of seconds from 0 to 300 (the server's `validate_result_timeout`), else null. */
|
||||
export function resultTimeout(text) {
|
||||
@@ -104,29 +113,36 @@ function paint(input, control, value) {
|
||||
|
||||
/**
|
||||
* Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes: "Browsing & display",
|
||||
* then the server's thumbnail settings (#317, system-thumbnails.mjs).
|
||||
* "Viewer" (D156), then the server's thumbnail settings (#317, system-thumbnails.mjs).
|
||||
*/
|
||||
export function mountAppearance(container, { scope, drafts, api, activity }) {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
surface.append(el('h2', 'Browsing & display'), el('p', 'One set of defaults; toolbar changes apply to the current view.'));
|
||||
const controls = [];
|
||||
for (const row of APPEARANCE_ROWS) {
|
||||
if (row === 'continuous') { surface.append(continuousRow()); continue; }
|
||||
const [id, label, description, domain, key, control] = row;
|
||||
const onChange = value => {
|
||||
if (key === 'result_timeout') {
|
||||
const seconds = resultTimeout(input.value);
|
||||
if (seconds === null) { drafts.reject(domain, key); return; }
|
||||
value = seconds;
|
||||
}
|
||||
drafts.set(domain, key, value);
|
||||
};
|
||||
const node = settingRow({ id, label, description, control, scope, onChange });
|
||||
surface.append(node);
|
||||
const input = controlOf(node, id);
|
||||
input.dataset.key = key;
|
||||
controls.push({ input, control, domain, key, text: node.children[0], note: null });
|
||||
function addRows(target, rows) {
|
||||
for (const row of rows) {
|
||||
if (row === 'continuous') { target.append(continuousRow()); continue; }
|
||||
const [id, label, description, domain, key, control] = row;
|
||||
const onChange = value => {
|
||||
if (key === 'result_timeout') {
|
||||
const seconds = resultTimeout(input.value);
|
||||
if (seconds === null) { drafts.reject(domain, key); return; }
|
||||
value = seconds;
|
||||
}
|
||||
drafts.set(domain, key, value);
|
||||
};
|
||||
const node = settingRow({ id, label, description, control, scope, onChange });
|
||||
target.append(node);
|
||||
const input = controlOf(node, id);
|
||||
input.dataset.key = key;
|
||||
controls.push({ input, control, domain, key, text: node.children[0], note: null });
|
||||
}
|
||||
}
|
||||
addRows(surface, APPEARANCE_ROWS);
|
||||
// D156: the viewer's defaults in their own surface.
|
||||
const viewer = el('section', undefined, 'surface');
|
||||
viewer.append(el('h2', 'Viewer'), el('p', 'How the image viewer opens; the button beside ✕ switches the current view.'));
|
||||
addRows(viewer, VIEWER_ROWS);
|
||||
surface.append(operationRow({ title: 'Reset display defaults',
|
||||
description: 'Restore this browser’s display defaults, then save. Other settings are kept.', label: 'Reset', scope,
|
||||
onClick: () => {
|
||||
@@ -146,5 +162,5 @@ export function mountAppearance(container, { scope, drafts, api, activity }) {
|
||||
};
|
||||
scope.cleanup(drafts.subscribe(repaint));
|
||||
repaint();
|
||||
return [surface, mountThumbnailSettings(container, { scope, drafts, api, activity })];
|
||||
return [surface, viewer, mountThumbnailSettings(container, { scope, drafts, api, activity })];
|
||||
}
|
||||
|
||||
@@ -180,6 +180,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* #249 D34: under 200% page zoom the concept's 94dvh outgrows the viewport; the dialog keeps within it. */
|
||||
.ca-tools #viewer-dialog { max-height: min(94dvh, 94%); /* D34 */ }
|
||||
#viewer-dialog .dialog-header > div { min-width: 0; }
|
||||
/* #450 D156: the view switch sits before ✕ (8px apart; the header's gap is the concept's 18px), and Full screen fills the
|
||||
browser window edge to edge (no outer margin), beating the concept's box, D34's max-height and the ≤700px height.
|
||||
At ≤700px the viewer is already full-bleed, so the switch hides there. */
|
||||
#viewer-dialog .dialog-header > #viewer-mode { margin-inline-start: auto; margin-inline-end: -10px; }
|
||||
.ca-tools #viewer-dialog.viewer-fullscreen[open] { inset: 0; width: auto; height: auto; max-width: none; max-height: none; margin: 0; border: 0; border-radius: 0; }
|
||||
/* `!important` only to beat the concept's `.icon-btn { display: inline-grid !important }`. */
|
||||
@media (max-width: 700px) { #viewer-dialog .dialog-header > #viewer-mode { display: none !important; } }
|
||||
#viewer-title { overflow-wrap: anywhere; /* D34 */ }
|
||||
#viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; }
|
||||
#viewer-details { overscroll-behavior: contain; /* D34 */ }
|
||||
|
||||
Reference in New Issue
Block a user