fix(inspect): final review round for the local Inspect page

- Each inline preview owns its listeners, so a released node is never
  pinned; a video released while still starting is reset with load()
  once it reports metadata or an error (I1).
- The viewer's video teardown on a just-started local video is guarded
  by a 15-page WebKit test; it needed no change (I2).
- The chooser keeps naming the current file after a cancelled picker
  and names the dropped file after an accepted drop (I3).
- The unrelated activity-scan.mjs floor raise is reverted (I4).
- The page owns pagehide: the viewer closes and the inline media goes
  before the session revokes the URL, and the chooser is cleared (M1,
  M2); the session gains clear() and the viewer close().
- The "metadata is available" toast shows only for readable metadata
  (M3); a rejected drop is a second, transient alert that never
  replaces the selection's status (M4).
- TESTING.md: D62/D63 updates, the WebKit loadstart follow-up and the
  pair-node follow-up (M5).
This commit is contained in:
Vito Sansevero
2026-10-22 15:36:34 -07:00
parent 9455abb17c
commit a906176fdf
8 changed files with 303 additions and 53 deletions
+28 -7
View File
@@ -1468,8 +1468,11 @@ beacon, WebSocket, EventSource); Inspect writes nothing to `localStorage`,
`sessionStorage` or IndexedDB; only an explicit Send crosses the host bridge, and its
payload carries neither the `blob:` URL nor the filename; no console message names the
file or quotes its prompts (asserted on the managed host). The selection's `blob:` URL belongs to one local session and is revoked once,
on replacement, route change, unmount or `pagehide`, never while an element holds it
(`URLs and media end with their owning session`).
on replacement, route change, unmount or `pagehide`, never while an element in the page holds it: the inline preview
and, when it is open, the viewer drop their media first. The page, not the session, owns `pagehide`: it closes the
viewer, releases the inline media, then ends the selection and clears the chooser, so a page restored from the
back-forward cache shows an empty chooser beside the empty selection (`URLs and media end with their owning
session`, which also leaves with the viewer open).
Recorded deviations on this page (maintainer rulings LI-3, LI-6; plan "Mock inventory and
mapped differences"):
@@ -1488,10 +1491,18 @@ mapped differences"):
video previews in the same `#local-preview` slot as `video.local-file-preview` with
native `controls`, `playsInline`, `preload="metadata"` and `muted`, never autoplays
inline, and is paused, has its `src` removed and is removed from the page on replacement
or dispose, before its URL is revoked. A settled video (metadata read, or failed) is then
reset with `load()`; one still starting is not, because WebKit (GStreamer) blocked the
page when such a pipeline was reset (`replacing a video that just started loading never
blocks the page`).
or dispose, before its URL is revoked. Each preview owns its listeners, so the page never
pins a released node. A settled video (metadata read, or failed) is then reset with
`load()` at once; one still starting is reset only once it reports metadata or an error,
because WebKit (GStreamer) blocked the page when such a pipeline was reset (`replacing a
video that just started loading never blocks the page`, `a video replaced while still
starting is reset once it settles`). A WebKit load cut off by the revoked URL usually
stalls and never settles; a fallback then only drops the waiting listeners. The viewer's
own video teardown was checked the same way (`closing the viewer on a video that just
started never blocks the page`, 15 fresh pages on WebKit) and needed no change.
Follow-up: on WebKit, a video released exactly at its `loadstart` sometimes blocks the
page, with or without any reset and also before the deferred reset (2 of 24 runs in the
pinned image), so the `loadstart` spec is skipped on WebKit until that is investigated.
- **D63** (status line): one `p.small.spaced-16` in `#local-preview`, for states the
concept never shows: "Drop one file at a time.", "Folders cannot be inspected. Choose
one image or video file.", "This file type cannot be previewed or inspected here.
@@ -1499,6 +1510,11 @@ mapped differences"):
original remain available in the viewer." and "Embedded metadata could not be read."
(the shared inspector's own copy) are `role="alert"`; "Metadata in AVI and WMV files
is not read here. The preview and the original remain available." is `role="status"`.
A rejected chooser or drop ("Drop one file at a time.", folders, an unsupported drop)
keeps the selection and its status line: its alert is a second line after it, removed
by the next selection attempt or after the concept's toast interval (6.5 s). The toast
"Local preview only…; metadata is available above." shows only for a selection whose
status is empty or the AVI/WMV note, never beside an unreadable or failed preview.
- **D64** (drop, LI-6): `dragover`/`drop` on `.file-drop` accept exactly one File, read
only `dataTransfer.files`/`items` (never its strings) and change nothing visually.
While the page is mounted, `window` `dragover`/`drop` call `preventDefault`, so a missed
@@ -1514,7 +1530,9 @@ mapped differences"):
The chooser keeps naming the chosen file, as the concept's does: it is cleared as the
picker opens (so choosing the same file again is a new selection), not after `change`,
and an accepted drop clears it so it never names a replaced file.
and named again with the current File (through a `DataTransfer`, which fires no
`change`) when the picker is cancelled. An accepted drop names the dropped file, so the
chooser never names a replaced file and never reads "No file chosen" beside a selection.
Legacy floor (`web/metadata.html`), dispositions:
- PNG by chooser and by drop: kept (`#local-file`, D64).
@@ -1556,6 +1574,9 @@ Nodes 2.0, switching both ways with a graph reload each time. Each send previews
node's current and new roles, writes exactly those roles, and neither queues nor loads a
workflow. A connected `negative_text` input rejects the whole preview with no change. The
workspace page makes no non-GET request and logs neither filename nor prompt text.
Follow-up (pair node, separate investigation): the node's `text` widget ignored a value
set after a `PrimitiveString` linked `negative_text` (it kept its prior value), so this spec
and `library-sender.spec.mjs` set the positive before connecting the negative input.
```sh
node --test tests/js/workspace-local-session.test.js tests/js/workspace-state.test.js
+159 -14
View File
@@ -227,6 +227,8 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
// The chooser names the dropped file, never the one it replaced and never "No file chosen".
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
const first = await preview(page).locator('img').getAttribute('src');
// Two files, a link and markup keep the selection and say why.
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
@@ -256,10 +258,16 @@ test('cancel and same-file reselect', async ({ page }) => {
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
await expect(status(page)).toHaveCount(0);
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
// Opening the picker and cancelling it keeps naming the live selection (modern engines fire `cancel`, not `change`).
await chooser(page).dispatchEvent('click');
await chooser(page).dispatchEvent('cancel');
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
// The same file again is a new selection with its own URL; the old one is released. Opening the picker clears
// the chooser, so the browser reports the same file as a change.
await chooser(page).dispatchEvent('click');
await expect(chooser(page)).toHaveJSProperty('value', '');
// (Read in the same task: a picker the click opens is cancelled by the harness, which names the file again.)
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
await choose(page, parityPng('f02.png'));
await expect(preview(page).locator('img')).not.toHaveAttribute('src', first);
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
@@ -325,6 +333,40 @@ test('local video plays inline and in the viewer with real tags', async ({ page,
if (browserName !== 'webkit') await expect.poll(() => viewer(page).locator('#viewer-stage video').evaluate(node => node.readyState >= 1)).toBe(true);
});
test('the toast and a rejected drop never contradict the status line', async ({ page }) => {
// A file whose bytes are gone after choosing: the status says its metadata cannot be read, so no toast says it is
// available.
await page.addInitScript(() => {
const slice = Blob.prototype.slice;
Blob.prototype.slice = function (...args) {
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
return slice.apply(this, args);
};
});
await openInspect(page);
await choose(page, parityPng('f02.png', 'gone.png'));
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(page.locator('#workspace-toast')).toHaveText('');
// A rejected drop is its own alert beside the selection's status, which it never replaces, and it ends with the
// concept's toast interval.
await choose(page, undecodableMp4());
const failed = status(page).filter({ hasText: COPY.previewFailed });
await expect(failed).toHaveCount(1);
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
const rejected = status(page).filter({ hasText: COPY.multiple });
await expect(rejected).toHaveAttribute('role', 'alert');
await expect(failed).toHaveCount(1);
await expect(rejected).toHaveCount(0, { timeout: 15000 });
await expect(status(page)).toHaveText([COPY.previewFailed]);
// The next selection attempt ends a rejection at once.
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
await expect(rejected).toHaveCount(1);
await chooser(page).dispatchEvent('click');
await chooser(page).dispatchEvent('cancel');
await expect(status(page)).toHaveText([COPY.previewFailed]);
});
test('undecodable video keeps container metadata', async ({ page }) => {
await openInspect(page);
// A real MP4 whose sample entry names a codec no engine decodes; its container tags are intact.
@@ -337,22 +379,25 @@ test('undecodable video keeps container metadata', async ({ page }) => {
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
});
/** Records, for each revoked URL, how many elements (inline or in the viewer) still held it, and counts resets. */
const trackReleases = page => page.evaluate(() => {
window.__released = [];
const revoke = URL.revokeObjectURL;
URL.revokeObjectURL = url => {
const live = [...document.querySelectorAll('img, video')].filter(node => node.getAttribute('src') === url);
window.__released.push({ url, live: live.length });
return revoke(url);
};
const load = HTMLMediaElement.prototype.load;
window.__loads = 0;
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) window.__loads++; return load.call(this); };
});
test('URLs and media end with their owning session', async ({ page }) => {
await instrument(page);
await openInspect(page);
// Every release pauses the media and drops its src before the URL is revoked.
await page.evaluate(() => {
window.__released = [];
const revoke = URL.revokeObjectURL;
URL.revokeObjectURL = url => {
const live = [...document.querySelectorAll('#local-preview img, #local-preview video')].filter(node => node.getAttribute('src') === url);
window.__released.push({ url, live: live.length });
return revoke(url);
};
const load = HTMLMediaElement.prototype.load;
window.__loads = 0;
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) window.__loads++; return load.call(this); };
});
await trackReleases(page);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(preview(page).locator('video')).toBeVisible();
// A settled video (metadata read, or failed) is reset with load() when it is released.
@@ -391,6 +436,26 @@ test('URLs and media end with their owning session', async ({ page }) => {
state = await urls(page);
expect(state.created).toHaveLength(8);
expect(state.revoked.filter(url => url === state.created[7])).toHaveLength(1);
// Page leave with the viewer open, on a page loaded at Inspect (so its listeners precede the shell's): the viewer
// closes and releases its media before the URL is revoked, and a page restored from the back-forward cache shows
// an empty chooser beside the empty selection.
await page.reload();
await expect(chooser(page)).toBeAttached();
await trackReleases(page);
await choose(page, parityPng('f02.png', 'leave-open.png'));
await openViewer(page);
await expect(viewer(page).getByRole('button', { name: 'Zoom in', exact: true })).toHaveAttribute('aria-disabled', 'false');
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
await expect(page.locator('dialog')).toHaveCount(0);
state = await urls(page);
expect(state.created).toHaveLength(1);
expect(state.revoked).toEqual(state.created);
expect(await page.evaluate(() => window.__released)).toEqual([{ url: state.created[0], live: 0 }]);
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
await expect(chooser(page)).toBeAttached();
expect(await chooser(page).evaluate(node => ({ value: node.value, files: node.files.length }))).toEqual({ value: '', files: 0 });
await expect(preview(page).locator('img, video')).toHaveCount(0);
await expect(action(page)).toHaveAttribute('aria-disabled', 'true');
});
test('replacing a video that just started loading never blocks the page', async ({ context, browserName }) => {
@@ -415,6 +480,86 @@ test('replacing a video that just started loading never blocks the page', async
}
});
test('a video replaced while still starting is reset once it settles', async ({ context, browserName }) => {
// A WebKit (GStreamer) video released exactly at its `loadstart` sometimes blocks the page, with or without any
// reset and also with the code before the deferred reset (2 of 24 runs); tracked in TESTING.md (D62). WebKit's
// no-hang guard is `replacing a video that just started loading never blocks the page`.
test.skip(browserName === 'webkit', 'WebKit release at loadstart is a tracked follow-up (D62)');
test.setTimeout(120000);
// The video is replaced from its own `loadstart` (readyState HAVE_NOTHING). It leaves the page with no src before
// its URL is revoked, and is reset (load() with no src, which frees its decoder) only once it reports metadata or
// an error, so the page never blocks. Fresh pages share one browser process.
for (let attempt = 0; attempt < 5; attempt++) {
const page = await context.newPage();
try {
if (attempt === 0) await startCoverage(page, browserName);
await page.addInitScript(() => {
const load = HTMLMediaElement.prototype.load;
HTMLMediaElement.prototype.load = function () { if (!this.hasAttribute('src')) this.__resets = (this.__resets ?? 0) + 1; return load.call(this); };
const revoke = URL.revokeObjectURL;
URL.revokeObjectURL = url => {
const starting = window.__starting;
if (url === starting?.url) starting.revoked = { src: starting.node.hasAttribute('src'), connected: starting.node.isConnected, resets: starting.node.__resets ?? 0 };
return revoke(url);
};
});
await openInspect(page);
await page.evaluate(() => {
const input = document.querySelector('#local-file'), slot = document.querySelector('#local-preview');
new MutationObserver((records, observer) => {
const video = slot.querySelector('video');
if (!video) return;
observer.disconnect();
video.addEventListener('loadstart', () => {
window.__starting = { node: video, url: video.getAttribute('src'), readyState: video.readyState };
input.files = window.__pngFiles; input.dispatchEvent(new Event('change', { bubbles: true }));
}, { once: true });
}).observe(slot, { childList: true });
});
const png = parityPng('f02.png');
await page.evaluate(bytes => {
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array(bytes)], 'f02.png', { type: 'image/png' }));
window.__pngFiles = transfer.files;
}, [...png.buffer]);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
await expect(preview(page).locator('video')).toHaveCount(0);
// Released while starting, and never reset synchronously: the URL went once the node had no src.
expect(await page.evaluate(() => ({ readyState: window.__starting.readyState, revoked: window.__starting.revoked })))
.toEqual({ readyState: 0, revoked: { src: false, connected: false, resets: 0 } });
await expect.poll(() => page.evaluate(() => window.__starting.node.__resets ?? 0), { timeout: 15000 }).toBe(1);
// The page still answers.
await chooser(page).setInputFiles(parityPng('f02.png', 'after.png'), { timeout: 8000 });
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
if (attempt === 0) await saveCoverage(page, browserName);
} finally {
await page.close();
}
}
});
test('closing the viewer on a video that just started never blocks the page', async ({ context, browserName }) => {
test.setTimeout(180000);
// The viewer's video teardown resets its element; on WebKit a reset of a still-starting pipeline blocked the page.
for (let attempt = 0; attempt < 15; attempt++) {
const page = await context.newPage();
try {
if (attempt === 0) await startCoverage(page, browserName);
await openInspect(page);
await choose(page, localFixture('core-tags.webm', 'video/webm'));
await openViewer(page);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0, { timeout: 8000 });
await chooser(page).setInputFiles(parityPng('f02.png'), { timeout: 8000 });
await expect(preview(page).locator('img.local-file-preview')).toBeVisible({ timeout: 8000 });
if (attempt === 0) await saveCoverage(page, browserName);
} finally {
await page.close();
}
}
});
test('replacing a file mid-read never shows or sends the old record', async ({ page, context }) => {
const host = await bridgeHost(context);
try {
+16
View File
@@ -389,4 +389,20 @@ test("dispose, parent abort and page leave each release the URL once", async ()
assert.equal((await recorded(third.session.controller)).record.prompts.positive.text, "after return");
third.session.dispose();
assert.equal(third.urls.revoked.length, 2);
// A page that owns page leave passes no pageTarget and ends the selection with clear(), as pagehide would.
const owned = session({ overrides: { pageTarget: null } });
owned.session.select([pngFile("a.png")]);
await recorded(owned.session.controller);
assert.equal(owned.page.listeners.size, 0, "no pagehide listener of its own");
owned.session.clear();
assert.deepEqual(owned.urls.revoked, [owned.urls.created[0].url]);
assert.equal(owned.session.entry, null);
assert.equal(owned.session.state.phase, "empty");
owned.session.clear();
assert.equal(owned.urls.revoked.length, 1, "clear is idempotent");
assert.equal(owned.session.select([pngFile("b.png")]), "accepted");
await recorded(owned.session.controller);
owned.session.dispose();
owned.session.clear();
assert.equal(owned.urls.revoked.length, 2);
});
+1 -1
View File
@@ -16,7 +16,7 @@ test('disposed media viewer touches no DOM observers or requests', () => {
const view = module.createMediaViewer({container:forbidden,parentScope,inspectionController:forbidden,sender:forbidden,
onSelect:() => assert.fail('Disposed viewer selected an entry'),onRefresh:() => assert.fail('Disposed viewer refreshed')});
if (!aborted) view.dispose();
view.open(forbidden); view.stopSlideshow?.(); view.dispose(); view.dispose(); parentScope.dispose();
view.open(forbidden); view.stopSlideshow?.(); view.close(); view.dispose(); view.dispose(); parentScope.dispose();
}
});
+5 -5
View File
@@ -162,10 +162,10 @@
"statements": 97
},
"workspace/activity-scan.mjs": {
"lines": 96,
"branches": 73,
"lines": 95,
"branches": 64,
"functions": 98,
"statements": 96
"statements": 95
},
"workspace/activity-store.mjs": {
"lines": 98,
@@ -283,8 +283,8 @@
},
"workspace/inspect.mjs": {
"lines": 98,
"branches": 90,
"functions": 88,
"branches": 92,
"functions": 92,
"statements": 98
},
"workspace/inspection-state.mjs": {
+88 -24
View File
@@ -8,7 +8,7 @@ import { createWorkspaceIcon } from './icons.mjs';
import { createLocalSession, LOCAL_SESSION_MESSAGES } from './local-session.mjs';
import { localFileAdapter } from './local-inspection.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { showWorkspaceToast, TOAST_MS } from './toast.mjs';
/** D60/D61 copy (maintainer ruling LI-3). */
export const INSPECT_COPY = Object.freeze({
@@ -37,17 +37,38 @@ function element(tag, className, 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 released video still starting may take to settle before its deferred reset is dropped.
const DEFERRED_RESET_MS = 15000;
/**
* Stops a preview from holding its blob: URL: media is paused and its src dropped before the session revokes the URL.
* A settled video (metadata read, or failed) is then reset with load() to free its decoder at once. A video still
* starting is not: WebKit (GStreamer) blocked the page when such a pipeline was reset, so it is left to be collected.
* Resets a released video once it settles. Removing `src` alone never aborts a load already under way, and WebKit
* (GStreamer) blocked the page when a pipeline still starting was reset, so load() waits for metadata or an error.
* The fallback only drops the waiting listeners (a WebKit load cut off by the revoked URL stalls and never settles),
* so the detached node is never pinned.
*/
function releaseMedia(node) {
if (!node) return;
function resetWhenSettled(node) {
const waiting = new AbortController();
const fallback = setTimeout(() => waiting.abort(), DEFERRED_RESET_MS);
const reset = () => { waiting.abort(); clearTimeout(fallback); node.load(); };
for (const type of ['loadedmetadata', 'error']) node.addEventListener(type, reset, { once: true, signal: waiting.signal });
}
/**
* Stops a preview from holding its blob: URL: its own listeners end, media is paused and its src dropped before the
* session revokes the URL, and the node leaves the page. A settled video (metadata read, or failed) is then reset with
* load() to free its decoder at once; one still starting is reset as soon as it settles.
*/
function releaseMedia(preview) {
if (!preview) return;
const { node, listeners } = preview;
listeners.abort();
if (node instanceof HTMLMediaElement) {
const settled = node.readyState >= HTMLMediaElement.HAVE_METADATA || node.error !== null;
node.pause(); node.removeAttribute('src');
if (settled) node.load();
else resetWhenSettled(node);
} else node.removeAttribute('src');
node.remove();
}
@@ -85,19 +106,44 @@ export function mountInspect(main, { parentScope, api, sender }) {
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();
const session = createLocalSession({ parentScope: sessionScope });
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null });
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, announced = null;
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;
// /* D63 */ one status line for the states the mock never shows.
statusLine = element('p', 'small spaced-16', message);
statusLine.setAttribute('role', ALERTS.has(message) ? 'alert' : 'status');
preview.append(statusLine);
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));
@@ -111,6 +157,8 @@ export function mountInspect(main, { parentScope, api, sender }) {
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;
@@ -118,13 +166,13 @@ export function mountInspect(main, { parentScope, api, sender }) {
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', () => {
if (media !== node) return;
releaseMedia(node); media = null;
releaseMedia(current); media = null;
showStatus(LOCAL_SESSION_MESSAGES.previewFailed);
}, { once: true, signal: scope.signal });
}, { once: true, signal: current.listeners.signal });
node.src = url;
media = node;
media = current;
preview.prepend(node);
}
scope.cleanup(session.subscribe(state => {
@@ -134,7 +182,8 @@ export function mountInspect(main, { parentScope, api, sender }) {
setAvailable(state.phase === 'ready' && !!entry);
if (state.phase === 'ready' && entry && announced !== entry.key) {
announced = entry.key;
showWorkspaceToast(INSPECT_COPY.toast);
// 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
@@ -143,17 +192,32 @@ export function mountInspect(main, { parentScope, api, sender }) {
scope.cleanup(() => { releaseMedia(media); media = null; mediaUrl = null; });
scope.cleanup(() => sessionScope.dispose());
function choose(source) {
// 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;
session.clear();
chosen = null; input.value = '';
}
scope.listen(window, 'pagehide', leave);
function choose(source, file) {
clearRejection();
const outcome = session.select(source);
if (REJECTIONS[outcome]) showStatus(REJECTIONS[outcome]);
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.
scope.listen(input, 'click', () => { input.value = ''; });
// 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.
choose(input.files?.length ? input.files : null);
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 => {
@@ -164,8 +228,8 @@ export function mountInspect(main, { parentScope, api, sender }) {
event.preventDefault();
if (document.querySelector('dialog[open]')) return;
const transfer = event.dataTransfer;
// A dropped file replaces the chosen one, so the chooser no longer names the old file.
if (choose(transfer ? { files: transfer.files, items: transfer.items } : null) === 'accepted') input.value = '';
// 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());
+2
View File
@@ -164,6 +164,8 @@ export function createLocalSession({
classify(file, generation, current.signal);
return 'accepted';
},
/** Ends the current selection as page leave does; the session stays usable. */
clear: leave,
retry() {
if (disposed || !handle) return undefined;
return controller.retry();
+4 -2
View File
@@ -24,7 +24,9 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
let active = null, retryVersion = 0;
const dispose = () => scope.dispose();
const stopSlideshow = () => active?.stop();
if (parentScope.signal.aborted) { scope.dispose(); return {open() {},dispose,stopSlideshow}; }
// 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));
@@ -240,5 +242,5 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
if (state.record?.identity.key === currentKey && state.record.library?.title) title.textContent = `${state.record.library.title} · ${entry.filename}`;
}));
}
return {open,dispose,stopSlideshow};
return {open,close,dispose,stopSlideshow};
}