* feat(trash): a freedesktop trash backend that renames without replacing or refuses (#480) * feat(images): remove a file's records and hide it with a removal mark (#480) Deletes every record of one file under every recorded spelling (image links, tags, auto-tag marks, pending review items, category, collection memberships, favorite) in one transaction. A Needs review item that is kept keeps its annotation rows (ON DELETE RESTRICT). The image_removals table hides an exact file (locator, size, mtime_ns) until it changes; linking skips a marked file, and an explicit tag, category, favorite or collection write shows it again. * feat(images): review and apply Delete images as a reviewed plan job (#480) POST /prompt_manager/images/delete/preview reviews both modes; the maintenance apply takes {token, mode}. Library mode removes the records and writes the removal mark; trash mode moves the file first and removes its records only after a successful move. The workspace listing, the output scan, the duplicate find and Tag output images skip marked files. * feat(images): Delete… in the Images selection bar, inspector and viewer (#480) The concept's Remove links… place, D159: one review per selection that asks each time (nothing preselected) whether to remove the files from the library only or also move them to the trash. The browser test server uses a fake trash. * test(images): portable delete tests, the native no-trash proof, D159 in TESTING.md (#480) * perf(images): cache the removal marks so a listing page reads no extra table (#480) The marks reload only when this process writes or clears one, or a restore replaces the database. The route snapshot gains the preview route. * test(images): the full inspector specs expect D159's Delete… in the panel and the viewer (#480) * fix(images): the delete review starts nothing without a chosen mode, and dims its primary until then (#480) An aria-disabled primary ignores every activation, a chosen one starts once, and the apply helper refuses a missing mode. Tests cover a forced click, a double click, Enter and Space on the primary, Enter in the dialog and a submit event, and the server refusing an apply without an exact mode.
310 lines
22 KiB
JavaScript
310 lines
22 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';
|
||
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.
|
||
const PENDING_VIEW_DESTROY_MS = 10000;
|
||
/** The video defaults, read through the shared display defaults (preferences.mjs, #249 System S3). */
|
||
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
|
||
const {videoAutoplay,videoMute,videoLoop} = readPreferences(getStorage,normalize).values;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* #331 D139: with the client store `collections`, the viewer's inspector offers "Collections…"; the changes it confirms
|
||
* reach `onCollectionsChanged({added, removed})` once the viewer closes, so the viewer keeps its place while open.
|
||
*/
|
||
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter,
|
||
collections = null,onCollectionsChanged = null,onDelete = null}) {
|
||
const scope = createScope();
|
||
let active = null, retryVersion = 0;
|
||
const dispose = () => scope.dispose();
|
||
const stopSlideshow = () => active?.stop();
|
||
// Closes an open viewer without restoring focus (page leave); the viewer stays usable.
|
||
const close = () => active?.close(false);
|
||
if (parentScope.signal.aborted) { scope.dispose(); return {open() {},close,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,details:detailsMode} = options;
|
||
let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false;
|
||
let originalDownload = null, inspectionState = inspectionController.state, collectionChanges = null;
|
||
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;
|
||
// #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');
|
||
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);
|
||
// #480: Delete… (the Images viewer only) pauses the media, then opens the delete review for this file.
|
||
if (onDelete) {
|
||
const remove = createButton('Delete…','danger');
|
||
local.listen(remove,'click',() => {
|
||
session.stop();
|
||
for (const video of dialog.querySelectorAll('video')) video.pause();
|
||
if (entry) onDelete(entry,remove);
|
||
});
|
||
controls.append(remove);
|
||
}
|
||
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. D156 (#450):
|
||
// the toggle is also there while the view is full screen.
|
||
const collapsible = displayPreferences()?.sidebarCollapsedByDefault === true, collapsed = collapsible && detailsMode !== 'shown';
|
||
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');
|
||
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(),
|
||
onLoad:() => inspectionController.load?.(),
|
||
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined,collections,
|
||
onCollectionsChanged:({added,removed}) => {
|
||
collectionChanges ??= {added:[],removed:[]};
|
||
collectionChanges.added.push(...added);collectionChanges.removed.push(...removed);
|
||
}});
|
||
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?.();
|
||
if (collectionChanges && !scope.signal.aborted) onCollectionsChanged?.(collectionChanges);
|
||
}
|
||
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, ViewerJs = window.Viewer;
|
||
// Transforms follow the Viewer.js this media was built with, which builds once the image has loaded.
|
||
withoutViewer = !ViewerJs;
|
||
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
|
||
// An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server
|
||
// original gets a fresh cache-buster on every build.
|
||
let original, src;
|
||
try { if (adapter.mediaUrl) src = adapter.mediaUrl(selected); else original = adapter.originalDownload(selected); }
|
||
catch { session.failed(key);return; }
|
||
if (adapter.mediaUrl) {
|
||
if (typeof src !== 'string' || !src) { session.failed(key);return; }
|
||
} else {
|
||
const url = new URL(original.url,window.location.href);
|
||
url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`);
|
||
src = url.href;
|
||
}
|
||
const node = element(selected.is_video ? 'video' : 'img');
|
||
// #430: the page may find the file under a renumbered source (`onMediaError`).
|
||
mediaLocal.listen(node,'error',() => {if (current()) {session.failed(key);onMediaError?.(selected);}});
|
||
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 = !!ViewerJs;
|
||
// With Viewer.js the src is released after the viewer (see release below).
|
||
if (!ViewerJs) {
|
||
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
|
||
mediaLocal.cleanup(() => node.removeAttribute('src'));
|
||
}
|
||
}
|
||
node.src = src;stage.append(node);
|
||
if (!selected.is_video && ViewerJs) {
|
||
// Viewer.js is built on an image that has loaded. Its own cancel removes its image listeners only
|
||
// while `image.complete` is false, so an image left with its load event still queued would build a
|
||
// viewer into the released image after destroy() (#293). Until then no viewer exists to cancel.
|
||
let mountedViewer = false;
|
||
mediaLocal.listen(node,'load',() => { if (current()) { mountedViewer = true;mountViewer(); } },{once:true});
|
||
mediaLocal.cleanup(() => { if (!mountedViewer) node.removeAttribute('src'); });
|
||
}
|
||
function mountViewer() {
|
||
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 ViewerJs(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);
|
||
// Before Viewer.js has built (its zero-delay build is pending), destroy() cancels that build;
|
||
// deferring it would let the build run into the image the stage has just dropped.
|
||
// Once built, 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 || !instance.ready) { 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.' : withoutViewer ?
|
||
'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,close,dispose,stopSlideshow};
|
||
}
|