288 lines
15 KiB
JavaScript
288 lines
15 KiB
JavaScript
// The concept's "Inspect a file" page (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729):
|
||
// one local file, chosen or dropped, previews inline; the heading's one action opens the shared viewer with the full
|
||
// inspector on a record read entirely in this browser. Nothing is uploaded, imported or stored. The selection, its
|
||
// blob: URL and its record belong to one local session (local-session.mjs) that ends with this page.
|
||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||
import { pageHeading } from './heading.mjs';
|
||
import { createWorkspaceIcon } from './icons.mjs';
|
||
import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs';
|
||
import { localFileAdapter } from './local-inspection.mjs';
|
||
import { loadInspectionSemantics } from './local-semantics.mjs';
|
||
import { createMediaViewer } from './media-viewer.mjs';
|
||
import { showWorkspaceToast, TOAST_MS } from './toast.mjs';
|
||
|
||
/** D60/D61 copy (maintainer ruling LI-3). */
|
||
export const INSPECT_COPY = Object.freeze({
|
||
action: 'Open file metadata',
|
||
heading: 'Inspect without importing',
|
||
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
|
||
chooser: 'Choose an image or video for a local preview',
|
||
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
|
||
toast: 'Local preview only. No file uploaded; metadata is available above.',
|
||
});
|
||
// D62: the concept's image list plus the videos the local readers support (AVI/WMV preview only).
|
||
export const INSPECT_ACCEPT = 'image/png,image/jpeg,image/webp,video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi,.wmv';
|
||
const REJECTIONS = Object.freeze({
|
||
'rejected-multiple': LOCAL_SESSION_MESSAGES.multiple,
|
||
'rejected-directory': LOCAL_SESSION_MESSAGES.directory,
|
||
'rejected-empty': LOCAL_SESSION_MESSAGES.unsupported,
|
||
});
|
||
// D63: the status line is an alert for anything the person must act on; AVI/WMV's note is plain status.
|
||
const ALERTS = new Set([LOCAL_SESSION_MESSAGES.multiple, LOCAL_SESSION_MESSAGES.directory, LOCAL_SESSION_MESSAGES.unsupported,
|
||
LOCAL_SESSION_MESSAGES.previewFailed, LOCAL_SESSION_MESSAGES.unreadable]);
|
||
|
||
function element(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
// The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available".
|
||
const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]);
|
||
// How long a replaced video still starting may stay in place before it is released anyway.
|
||
const STARTING_VIDEO_MS = 15000;
|
||
|
||
/** A video whose pipeline is still starting: it has a source but neither metadata nor an error yet. */
|
||
function isStarting(node) {
|
||
return node instanceof HTMLMediaElement && node.hasAttribute('src') && node.readyState < HTMLMediaElement.HAVE_METADATA && node.error === null;
|
||
}
|
||
|
||
/**
|
||
* Stops a preview from holding its blob: URL: its own listeners end, media is paused, its src dropped and it is reset
|
||
* with load() (which frees its decoder), and the node leaves the page, all before its URL is revoked.
|
||
*/
|
||
function releaseMedia(preview) {
|
||
if (!preview) return;
|
||
const { node, listeners } = preview;
|
||
listeners.abort();
|
||
if (node instanceof HTMLMediaElement) {
|
||
node.pause(); node.removeAttribute('src'); node.load();
|
||
} else node.removeAttribute('src');
|
||
node.remove();
|
||
}
|
||
|
||
/**
|
||
* Mounts the page into `main` below the heading the shell rendered. `sender` is the shared prompt sender; `api` is
|
||
* only handed to the viewer (the local adapter never calls it). Returns `{dispose, leaveGuard}` (`{dispose}` when
|
||
* `parentScope` has already ended); the page also ends with `parentScope`. `leaveGuard()` is a promise while a video
|
||
* on the page is still starting (D62): the shell renders the next route once it settles, since detaching it then
|
||
* deadlocked WebKit.
|
||
*/
|
||
export function mountInspect(main, { parentScope, api, sender }) {
|
||
const scope = createScope();
|
||
const dispose = () => scope.dispose();
|
||
if (parentScope.signal.aborted) { scope.dispose(); return { dispose }; }
|
||
parentScope.signal.addEventListener('abort', dispose, { once: true });
|
||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||
// The node table the local roles resolve with, read before any file is chosen (one request per page).
|
||
loadInspectionSemantics();
|
||
|
||
// The concept DOM: section.surface > h2, p; div.file-drop.spaced-20 > icon, label, input, span.small; #local-preview.
|
||
const surface = element('section', 'surface');
|
||
const area = element('div', 'file-drop spaced-20');
|
||
const label = element('label', undefined, INSPECT_COPY.chooser);
|
||
label.htmlFor = 'local-file';
|
||
const input = element('input');
|
||
input.id = 'local-file'; input.type = 'file'; input.accept = INSPECT_ACCEPT;
|
||
const note = element('span', 'small', INSPECT_COPY.note);
|
||
note.id = 'local-file-note';
|
||
area.append(createWorkspaceIcon('upload'), label, input, note);
|
||
const preview = element('div');
|
||
preview.id = 'local-preview';
|
||
surface.append(element('h2', undefined, INSPECT_COPY.heading), element('p', undefined, INSPECT_COPY.intro), area, preview);
|
||
main.append(surface);
|
||
|
||
// /* D60 */ the concept's one primary heading action, unavailable until a classified file is chosen.
|
||
const open = createButton(INSPECT_COPY.action, 'ca-primary');
|
||
pageHeading(main)?.actions.append(open);
|
||
scope.cleanup(() => open.remove());
|
||
|
||
// The session ends after the viewer has closed and the inline media is gone, so no element holds its URL then.
|
||
// The page owns page leave for the same reason: the session listens for no `pagehide` of its own.
|
||
const sessionScope = createScope();
|
||
// Replaced videos still starting, by URL. WebKit (GStreamer) deadlocked the page when a video left the page or
|
||
// the viewport before its pipeline had started, so such a video stays where it is, inert, until it reports
|
||
// metadata or an error; only then is it released and its URL, which the session asked to revoke, revoked.
|
||
const retiring = new Map();
|
||
function finishRetiring(url) {
|
||
const entry = retiring.get(url);
|
||
if (!entry) return;
|
||
retiring.delete(url);
|
||
entry.waiting.abort(); clearTimeout(entry.timer);
|
||
releaseMedia(entry.preview);
|
||
if (entry.revoke) URL.revokeObjectURL(url);
|
||
entry.done();
|
||
}
|
||
function retire(preview, url) {
|
||
const waiting = new AbortController();
|
||
const entry = { preview, waiting, revoke: false, timer: setTimeout(() => finishRetiring(url), STARTING_VIDEO_MS) };
|
||
entry.finished = new Promise(resolve => { entry.done = resolve; });
|
||
retiring.set(url, entry);
|
||
preview.listeners.abort();
|
||
preview.node.inert = true; preview.node.setAttribute('aria-hidden', 'true');
|
||
for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, () => finishRetiring(url), { once: true, signal: waiting.signal });
|
||
}
|
||
const finishAllRetiring = () => { for (const url of [...retiring.keys()]) finishRetiring(url); };
|
||
const revokeObjectURL = url => {
|
||
const entry = retiring.get(url);
|
||
if (entry) entry.revoke = true;
|
||
else URL.revokeObjectURL(url);
|
||
};
|
||
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL });
|
||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
|
||
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } });
|
||
|
||
let media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null;
|
||
function statusElement(message) {
|
||
// /* D63 */ one status line for the states the mock never shows.
|
||
const line = element('p', 'small spaced-16', message);
|
||
line.setAttribute('role', ALERTS.has(message) ? 'alert' : 'status');
|
||
return line;
|
||
}
|
||
function showStatus(message) {
|
||
statusLine?.remove(); statusLine = null;
|
||
if (!message) return;
|
||
statusLine = statusElement(message);
|
||
preview.insertBefore(statusLine, rejectionLine);
|
||
}
|
||
function clearRejection() {
|
||
clearTimeout(rejectionTimer); rejectionTimer = 0;
|
||
rejectionLine?.remove(); rejectionLine = null;
|
||
}
|
||
// /* D63 */ a rejected chooser or drop keeps the selection and its status: its alert is a second line that ends
|
||
// with the next selection attempt or after the concept's toast interval.
|
||
function showRejection(message) {
|
||
clearRejection();
|
||
rejectionLine = statusElement(message);
|
||
preview.append(rejectionLine);
|
||
rejectionTimer = setTimeout(clearRejection, TOAST_MS);
|
||
}
|
||
scope.cleanup(clearRejection);
|
||
/** Keeps the chooser naming the current selection; assigning `files` fires no `change`. */
|
||
function nameChosen() {
|
||
if (!chosen) return;
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(chosen);
|
||
input.files = transfer.files;
|
||
}
|
||
function setAvailable(available) {
|
||
open.setAttribute('aria-disabled', String(!available));
|
||
if (available) open.removeAttribute('aria-describedby');
|
||
else open.setAttribute('aria-describedby', note.id);
|
||
}
|
||
function showMedia(state) {
|
||
const url = state.preview?.url ?? null;
|
||
if (url === mediaUrl) return;
|
||
if (media && isStarting(media.node) && !retiring.has(mediaUrl)) retire(media, mediaUrl);
|
||
else releaseMedia(media);
|
||
media = null; mediaUrl = url;
|
||
if (!url) return;
|
||
const video = state.preview.media === 'video';
|
||
const node = element(video ? 'video' : 'img', 'local-file-preview');
|
||
// Each preview owns its listeners, so a released node is never held by the page.
|
||
const current = { node, listeners: new AbortController() };
|
||
if (video) {
|
||
// /* D62 */ native controls, no inline autoplay.
|
||
node.controls = true; node.playsInline = true; node.preload = 'metadata'; node.muted = true;
|
||
} else {
|
||
node.alt = 'Local file preview';
|
||
}
|
||
// /* D65 */ the URL is not revoked on load: the viewer and Download original use it.
|
||
// The listener ends with the node's release, so it only fires for the current preview.
|
||
node.addEventListener('error', () => {
|
||
releaseMedia(current); media = null;
|
||
showStatus(LOCAL_SESSION_MESSAGES.previewFailed);
|
||
}, { once: true, signal: current.listeners.signal });
|
||
node.src = url;
|
||
media = current;
|
||
// A video still starting keeps its place, so the new preview goes after it and never moves it.
|
||
const anchor = [...retiring.values()].map(entry => entry.preview.node).filter(item => item.parentNode === preview).at(-1);
|
||
if (anchor) anchor.after(node);
|
||
else preview.prepend(node);
|
||
}
|
||
scope.cleanup(session.subscribe(state => {
|
||
showMedia(state);
|
||
showStatus(state.message);
|
||
const entry = session.entry;
|
||
setAvailable(state.phase === 'ready' && !!entry);
|
||
if (state.phase === 'ready' && entry && announced !== entry.key) {
|
||
announced = entry.key;
|
||
// An unreadable or failed preview says so in its alert; the toast never claims metadata is available.
|
||
if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast);
|
||
}
|
||
}));
|
||
// A replacement or page leave revokes the URL after this subscriber dropped the media; a dispose closes the
|
||
// viewer (on abort), drops the media, then ends the session.
|
||
scope.cleanup(() => viewer.dispose());
|
||
scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; });
|
||
scope.cleanup(finishAllRetiring);
|
||
scope.cleanup(() => sessionScope.dispose());
|
||
|
||
// Page leave: the viewer closes and the inline media goes before the session revokes the URL, and the chooser is
|
||
// cleared, so a page restored from the back-forward cache never names a file beside the empty selection.
|
||
function leave() {
|
||
clearRejection();
|
||
viewer.close();
|
||
releaseMedia(media); media = null; mediaUrl = null;
|
||
finishAllRetiring();
|
||
session.clear();
|
||
chosen = null; input.value = '';
|
||
}
|
||
scope.listen(window, 'pagehide', leave);
|
||
|
||
function choose(source, file) {
|
||
clearRejection();
|
||
const outcome = session.select(source);
|
||
if (outcome === 'accepted') chosen = file;
|
||
if (REJECTIONS[outcome]) showRejection(REJECTIONS[outcome]);
|
||
return outcome;
|
||
}
|
||
// The chooser keeps showing the chosen name, as in the concept; it is cleared as the picker opens, so choosing
|
||
// the same file again is a new selection, and named again when the picker is cancelled.
|
||
scope.listen(input, 'click', () => { clearRejection(); input.value = ''; });
|
||
scope.listen(input, 'cancel', () => nameChosen());
|
||
scope.listen(input, 'change', () => {
|
||
// A cancelled chooser carries no files: the page stays as it is.
|
||
if (!input.files?.length) { choose(null); nameChosen(); return; }
|
||
choose(input.files, input.files[0]);
|
||
});
|
||
// /* D64 */ drop on the area, with no visual change; only `dataTransfer.files` is read, never its strings.
|
||
scope.listen(area, 'dragover', event => {
|
||
event.preventDefault();
|
||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||
});
|
||
scope.listen(area, 'drop', event => {
|
||
event.preventDefault();
|
||
if (document.querySelector('dialog[open]')) return;
|
||
const transfer = event.dataTransfer;
|
||
// A dropped file replaces the chosen one, so the chooser names the dropped file, never the old one.
|
||
if (choose(transfer ? { files: transfer.files, items: transfer.items } : null, transfer?.files?.[0]) === 'accepted') nameChosen();
|
||
});
|
||
// A drop that misses the area never navigates away from the page while it is mounted.
|
||
for (const type of ['dragover', 'drop']) scope.listen(window, type, event => event.preventDefault());
|
||
scope.listen(open, 'click', () => {
|
||
const entry = session.entry;
|
||
if (open.getAttribute('aria-disabled') === 'true' || !entry || session.state.phase !== 'ready') return;
|
||
viewer.open({ items: [entry], selectedKey: entry.key, trigger: open });
|
||
});
|
||
/** Settles when no video on the page is still starting; `null` when none is. */
|
||
function leaveGuard() {
|
||
if (scope.signal.aborted) return null;
|
||
const waits = [];
|
||
for (const entry of retiring.values()) waits.push(entry.finished);
|
||
if (media && isStarting(media.node)) {
|
||
const { node } = media;
|
||
waits.push(new Promise(resolve => {
|
||
const settled = new AbortController();
|
||
const done = () => { settled.abort(); clearTimeout(timer); resolve(); };
|
||
const timer = setTimeout(done, STARTING_VIDEO_MS);
|
||
for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, done, { once: true, signal: settled.signal });
|
||
scope.signal.addEventListener('abort', done, { once: true, signal: settled.signal });
|
||
}));
|
||
}
|
||
return waits.length ? Promise.all(waits) : null;
|
||
}
|
||
return { dispose, leaveGuard };
|
||
}
|