* test(inspector): concept comparison for the panel and viewer details Compare the Images side panel and the viewer's full details with the concept (1600x1100 and 390x844, both themes): structure, tokens, geometry and controls, plus copy, the acknowledged pair send, the tag chip filter and Prompt history. mapInspectorConcept applies only D3, D20, D21 and D22; a mutation test shows the comparison notices a changed role label and a removed footer. The comparison showed the role copy and send names lacked the concept's ": <title>" subject, so the inspector now passes its title to the prompt blocks. It also showed that a recipe-linked image shows its recipe's own category and tags (#224), which the concept fixture's recipes lack; that is recorded as D38, and the fixture's new positive_links option links the compared image to its positive alone. * docs(inspector): renumber the recipe-category decision to D39 * feat(images): local-only Show in folder route POST /prompt_manager/images/reveal takes the listing identity {root_index, root_scope, path} and opens this computer's file manager at that file. It answers 403 not-local unless the client is loopback, no proxy header is present and Host/Origin name this computer; the scope is checked with _gallery_scope_error (409) and the path resolves only inside that root to a regular media file. Openers run as argv lists with an absolute executable, no shell and DEVNULL streams (D38). * feat(inspector): Show in folder after the File & provenance path The viewer's File & provenance offers "Show in folder" right after the Path value for a listed file only, POSTs its listing identity and toasts the outcome (opened, not-local, unavailable or failed). The browser test server stubs the opener; TESTING.md records the decision as D38. * fix(images): keep the discovery alert clear of the Rescan toast A rescan that leaves the Images discovery alert visible could show its fixed bottom toast over the alert on a short window. The concept's toast keeps its placement; the production-only alert now scrolls into view only as far as needed, with a 96px block-end scroll margin (D42). * docs(inspector): concept panel and viewer details * fix(images): open Windows folders through the shell API, not an Explorer command line Explorer splits its own command line on commas, so a gallery path such as output\a,\Windows\System32\calc.exe,b\x.png escaped the roots through [explorer, "/select,", path] (security review H1). Windows now selects the file with ILCreateFromPathW and SHOpenFolderAndSelectItems, and on any shell failure falls back to os.startfile on the folder. No process is started. * fix(images): re-check, isolate and reap the Show in folder opener - re-check that the real path is still a regular file inside its root right before launching; a changed target answers 404 (review L4) - run openers on a dedicated single worker, not ComfyUI's default executor, and make dbus-send wait for the FileManager1 reply (--print-reply --reply-timeout=2000) so a missing service falls back to xdg-open (L1) - reap launched processes on a daemon thread, give them a minimal environment with a fixed system PATH, and use /usr/bin/open on macOS and dbus-send/xdg-open resolved only on that PATH (L5) * fix(images): accept Show in folder only as a same-origin JSON request, one at a time - require an Origin equal to the request's own scheme://host:port and, when sent, Sec-Fetch-Site: same-origin; refuse every forwarding-style header (Via, X-Forwarded-*, X-Original-*, *-Client-IP, CF-Connecting-IP and the rest) as remote (security review M1, L2) - answer a non-JSON body with 415, so a cross-origin request always needs a CORS preflight (L2) - single flight plus a 1 s cooldown per process: 429 code busy (L1) - path resolution errors such as ENAMETOOLONG are a JSON 404, and neither that nor a start failure logs the client's path at error level (L3) * fix(inspector): say the folder is already opening on a busy Show in folder A 429 busy from the reveal route now toasts "The folder is already opening." The browser workflow checks that the reveal POST carries the page's own Origin and an application/json body, and TESTING.md D38 records the tightened route, the toast table and the accepted residual risk of a header-less local proxy. * test(inspector): compare the viewer details under a coarse pointer and prove its mutations The touch project measured production's D34 40px disclosure targets against the concept's fine-pointer summaries. The concept's plain-chip copy now also carries the recorded D34 coarse rule, so both documents are measured under the same emulation. The open File & provenance is compared too (D38 Show in folder and D20 Download original mapped), and the mutation check now removes the viewer's pair actions and Show in folder. * refactor(inspector): attach Show in folder to the Path value it follows The button was placed on the second fact row by position; the Path dd is now built by the caller and kept, so reordering the facts cannot move it. * test(inspector): let the uncertain-file send check fail again Inspector send buttons are named "... to ComfyUI: <title>", so the anchored regex could never match and the no-send assertion always passed. * fix(images): run the Windows folder fallback inside COM and keep desktop session variables Show in folder's os.startfile fallback now runs inside the same CoInitializeEx/CoUninitialize bracket as the shell selection, as ShellExecute callers should. The Linux opener keeps XAUTHORITY, DESKTOP_SESSION, XDG_SESSION_DESKTOP and XDG_CONFIG_HOME, so a file manager can reach the X11 display under GDM or SDDM and honour the user's mimeapps.list. A Windows-only test calls the real ILCreateFromPathW and ILFree on a temp file and pairs CoInitializeEx with CoUninitialize through the module's helper, without SHOpenFolderAndSelectItems, so no window opens on CI. * fix(images): scroll to the rescan's discovery alert only when the toast band covers it D42 scrolled the alert into view after every rescan, so a user scrolled down the grid was sent back to the top. It now scrolls only when the alert intersects the toast's band (its scroll-margin-block-end). * refactor(inspector): name prompt actions as PR #263 does The title option keeps its name, default and output ("Copy positive prompt: <title>") and now uses #263's actionName helper; the region-name arrow no longer shadows the title option. * test(inspector): the comparison opens Filters by its D44 active-count name After a tag chip applies its filter, the shared toolbar's Filters button reads "Filters · 1" (D44, #263), so the comparison opens it by that name. * fix(images): POSIX opener paths stay absolute under Python 3.13 on Windows Python 3.13 on Windows no longer counts "/usr/bin/x" as absolute, so the faked-Linux reveal tests failed on windows-latest py3.13 (the Linux and macOS openers are POSIX paths whatever host evaluates them). Use posixpath.isabs for those lookups.
238 lines
16 KiB
JavaScript
238 lines
16 KiB
JavaScript
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||
import {createViewerSession} from './viewer-state.mjs';
|
||
import {rootedFileAdapter} from './inspection-state.mjs';
|
||
import {mountFileInspector} from './file-inspector.mjs';
|
||
|
||
let viewerId = 0;
|
||
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
|
||
const PENDING_VIEW_DESTROY_MS = 10000;
|
||
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
|
||
let stored;
|
||
try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; }
|
||
const {videoAutoplay,videoMute,videoLoop} = normalize(stored);
|
||
return {videoAutoplay,videoMute,videoLoop};
|
||
}
|
||
function element(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
|
||
const scope = createScope();
|
||
let active = null, retryVersion = 0;
|
||
const dispose = () => scope.dispose();
|
||
const stopSlideshow = () => active?.stop();
|
||
if (parentScope.signal.aborted) { scope.dispose(); return {open() {},dispose,stopSlideshow}; }
|
||
parentScope.signal.addEventListener('abort',dispose,{once:true});
|
||
scope.cleanup(() => parentScope.signal.removeEventListener('abort',dispose));
|
||
scope.cleanup(() => active?.close(false));
|
||
|
||
function open(options) {
|
||
if (scope.signal.aborted) return;
|
||
active?.close(false);
|
||
const {items,selectedKey,trigger = document.activeElement} = options;
|
||
let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false;
|
||
let originalDownload = null, inspectionState = inspectionController.state;
|
||
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
|
||
if (!mounted) { initial = [state,selected]; return; }
|
||
update(state,selected);
|
||
}});
|
||
const local = createScope();
|
||
// The concept's viewer shell (index.html h84–103, app.js a889–926): `dialog#viewer-dialog` named by its title,
|
||
// `.dialog-header`, then `.viewer-layout` > `.viewer-left` (`#viewer-stage`, `#viewer-toolbar`) and
|
||
// `aside#viewer-details`. #249 D34: production keeps a focusable stage, its controls' accessible names, the
|
||
// Download link and, after Next →, the position status and the two unavailability reasons.
|
||
const dialog = element('dialog');dialog.id = 'viewer-dialog';dialog.setAttribute('aria-labelledby','viewer-title');
|
||
const header = element('div','dialog-header');
|
||
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);
|
||
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');
|
||
const failure = element('div','media-failure');
|
||
const error = element('p',undefined,'Media could not be loaded. Retry this file.');
|
||
const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(failure);
|
||
const controls = element('div','viewer-toolbar');controls.id = 'viewer-toolbar';
|
||
controls.setAttribute('role','group');controls.setAttribute('aria-label','Media controls');
|
||
function button(label,text,action) {
|
||
const node = createButton(text);node.setAttribute('aria-label',label);
|
||
local.listen(node,'click',() => { if (node.getAttribute('aria-disabled') !== 'true') action(); });
|
||
controls.append(node);return node;
|
||
}
|
||
const previous = button('Previous media','← Previous',() => session.navigate(-1));
|
||
const transforms = [button('Zoom out','−',() => viewer.zoom(-0.2)),button('Zoom in','+',() => viewer.zoom(0.2)),
|
||
button('Fit image','Fit',() => viewer.reset()),button('Rotate image 90 degrees','Rotate',() => viewer.rotate(90)),
|
||
button('Flip image horizontally','Flip',() => viewer.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1))];
|
||
const slideshow = button('Play slideshow','Play',() => session.state.requested ? session.stop() : session.play());
|
||
const download = element('a','media-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
|
||
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
|
||
controls.append(download);
|
||
const next = button('Next media','Next →',() => session.navigate(1));
|
||
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');
|
||
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(),
|
||
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
|
||
function closeViewer(restore = true) {
|
||
if (closed) return;
|
||
closed = true;active = null;local.dispose();
|
||
if (restore && !scope.signal.aborted) {
|
||
if (trigger?.isConnected) trigger.focus({preventScroll:true});
|
||
else onRestoreFocus?.(entry);
|
||
}
|
||
if (!scope.signal.aborted) onClose?.();
|
||
}
|
||
active = {close:closeViewer,stop:() => session.stop()};
|
||
local.cleanup(() => {
|
||
session.dispose();mediaScope?.dispose();inspector.dispose();
|
||
if (dialog.open) dialog.close();dialog.remove();
|
||
});
|
||
local.listen(close,'click',() => closeViewer());local.listen(dialog,'close',() => closeViewer());
|
||
// Finish focus restoration before a later key event can select another control.
|
||
local.listen(dialog,'cancel',event => {event.preventDefault();closeViewer();});
|
||
local.listen(document,'visibilitychange',() => {if (document.hidden) session.stop();});
|
||
local.listen(window,'pagehide',() => closeViewer(false));
|
||
local.listen(retry,'click',() => {if (!inspectionState.refreshRequired) rebuild(entry);});
|
||
local.listen(dialog,'keydown',event => {
|
||
if (!['ArrowLeft','ArrowRight'].includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
|
||
(!stage.contains(event.target) && !controls.contains(event.target)) ||
|
||
event.target.closest('video,input,textarea,select,pre,summary,[contenteditable]') ||
|
||
!window.getSelection()?.isCollapsed || [...document.querySelectorAll('dialog[open]')].at(-1) !== dialog) return;
|
||
event.preventDefault();session.navigate(event.key === 'ArrowLeft' ? -1 : 1);
|
||
});
|
||
function rebuild(selected) {
|
||
mediaScope?.dispose();mediaScope = createScope();viewer = null;
|
||
for (const child of [...stage.children]) if (child !== failure) child.remove();
|
||
failure.hidden = true;
|
||
const mediaLocal = mediaScope, key = session.state.key;
|
||
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
|
||
let original;
|
||
try { original = adapter.originalDownload(selected); }
|
||
catch { session.failed(key);return; }
|
||
const url = new URL(original.url,window.location.href);
|
||
url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`);
|
||
const node = element(selected.is_video ? 'video' : 'img');
|
||
mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);});
|
||
if (selected.is_video) {
|
||
node.controls = true;node.playsInline = true;node.preload = 'metadata';
|
||
const preferences = readViewerPreferences();
|
||
node.muted = preferences.videoMute;node.loop = preferences.videoLoop;
|
||
mediaLocal.listen(node,'loadedmetadata',() => {if (current()) session.ready(key);});
|
||
mediaLocal.cleanup(() => {node.pause();node.removeAttribute('src');node.load();});
|
||
if (preferences.videoAutoplay) mediaLocal.listen(node,'loadedmetadata',() => {
|
||
if (current()) node.play().catch(() => {});
|
||
},{once:true});
|
||
} else {
|
||
node.alt = selected.filename;node.decoding = 'async';
|
||
node.hidden = !!window.Viewer;
|
||
// With Viewer.js the src is released after the viewer (see release below).
|
||
if (!window.Viewer) {
|
||
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
|
||
mediaLocal.cleanup(() => node.removeAttribute('src'));
|
||
}
|
||
}
|
||
node.src = url.href;stage.append(node);
|
||
if (!selected.is_video && window.Viewer) {
|
||
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
|
||
const release = () => {
|
||
if (released) return;
|
||
released = true;clearTimeout(fallback);instance.destroy();node.removeAttribute('src');
|
||
};
|
||
const scheduleFit = () => {
|
||
if (!current() || fitFrame) return;
|
||
fitFrame = requestAnimationFrame(() => {
|
||
fitFrame = 0;
|
||
if (!current() || !instance.viewed) return;
|
||
const width = stage.offsetWidth, height = stage.offsetHeight;
|
||
if (width === fitWidth && height === fitHeight) return;
|
||
fitWidth = width;fitHeight = height;instance.resize();
|
||
});
|
||
};
|
||
const instance = new window.Viewer(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
|
||
keyboard:false,fullscreen:false,transition:false,viewed() {
|
||
viewSettled = true;
|
||
if (mediaLocal.signal.aborted) { release(); return; }
|
||
if (!current()) return;
|
||
session.ready(key);scheduleFit();
|
||
}});
|
||
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
|
||
viewer = instance;mediaLocal.cleanup(() => {
|
||
observer.disconnect();cancelAnimationFrame(fitFrame);
|
||
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
|
||
// rebuilds its list from images that still have a src; keep both until the view
|
||
// settles, fails or times out.
|
||
if (viewSettled) { release(); return; }
|
||
node.addEventListener('error',release,{once:true});
|
||
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
|
||
});
|
||
}
|
||
updateControls(session.state);
|
||
}
|
||
function updateControls(state) {
|
||
const unavailable = inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? 'Media could not be loaded. Retry this file.' : entry.is_video ?
|
||
'Image transforms are unavailable for video.' : !viewer ?
|
||
'Image transforms are unavailable because Viewer.js could not load.' : !state.mediaReady ? 'Image transforms are available when the image is ready.' : '';
|
||
reason.textContent = unavailable;reason.hidden = !unavailable;
|
||
for (const node of transforms) {
|
||
node.setAttribute('aria-disabled',String(!!unavailable));
|
||
if (unavailable) node.setAttribute('aria-describedby',reason.id);else node.removeAttribute('aria-describedby');
|
||
}
|
||
for (const node of [previous,next]) {
|
||
node.setAttribute('aria-disabled',String(state.total === 1));
|
||
if (state.total === 1) node.setAttribute('aria-describedby',position.id);else node.removeAttribute('aria-describedby');
|
||
}
|
||
const action = state.phase === 'paused-video' ? 'Stop' : state.requested ? 'Pause' : 'Play';
|
||
slideshow.textContent = action;slideshow.setAttribute('aria-label',`${action} slideshow`);
|
||
const cannotPlay = !state.requested && (inspectionState.refreshRequired || state.phase === 'error' || state.total < 2 || entry.is_video);
|
||
slideshow.setAttribute('aria-disabled',String(!!cannotPlay));
|
||
slideshowReason.textContent = !cannotPlay ? '' : inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ?
|
||
'Media could not be loaded. Retry this file.' : state.total < 2 ? 'Slideshow requires at least two loaded media entries.' : 'Start slideshow on an image.';
|
||
slideshowReason.hidden = !cannotPlay;
|
||
if (cannotPlay) slideshow.setAttribute('aria-describedby',slideshowReason.id);else slideshow.removeAttribute('aria-describedby');
|
||
const canDownload = originalDownload && !inspectionState.refreshRequired;
|
||
download.setAttribute('aria-disabled',String(!canDownload));
|
||
if (canDownload) {
|
||
download.href = originalDownload.url;download.download = originalDownload.filename;
|
||
download.removeAttribute('tabindex');download.removeAttribute('aria-describedby');
|
||
} else {
|
||
download.removeAttribute('href');download.tabIndex = 0;download.setAttribute('aria-describedby',reason.id);
|
||
}
|
||
failure.hidden = state.phase !== 'error' || inspectionState.refreshRequired;
|
||
}
|
||
function update(state,selected) {
|
||
if (closed || state.phase === 'disposed') return;
|
||
entry = selected;
|
||
const status = {'waiting-image':'Slideshow waiting for image.','playing-image':'Slideshow playing.',
|
||
'paused-video':'Slideshow paused for video. Next or Previous continues it on an image; Stop ends slideshow.',error:'Media could not be loaded.'}[state.phase];
|
||
const label = `${state.index + 1} of ${state.total} loaded: ${entry.filename}${status ? ` — ${status}` : ''}`;
|
||
if (position.textContent !== label) position.textContent = label;
|
||
if (currentKey !== state.key) {
|
||
// A linked card already carries its title, so the header reads "<title> · <filename>" at once.
|
||
const known = entry.linked?.status === 'linked' && entry.linked.title;
|
||
currentKey = state.key;title.textContent = known ? `${known} · ${entry.filename}` : entry.filename;
|
||
try { originalDownload = adapter.originalDownload(entry); } catch { originalDownload = null; }
|
||
onSelect?.(entry);
|
||
rebuild(entry);
|
||
}
|
||
updateControls(session.state);
|
||
}
|
||
dialog.showModal();mounted = true;update(...initial);
|
||
local.cleanup(inspectionController.subscribe(state => {
|
||
inspectionState = state;updateControls(session.state);
|
||
if (state.error) session.stop();
|
||
inspector.setState(state);
|
||
if (state.record?.identity.key === currentKey && state.record.library?.title) title.textContent = `${state.record.library.title} · ${entry.filename}`;
|
||
}));
|
||
}
|
||
return {open,dispose,stopSlideshow};
|
||
}
|