fix(annotations): re-read a tagged file in place after a save
After a tag save the selected file's inspection is re-read quietly: the panel and the viewer keep the current details until the new answer lands, with no "Loading file details…" in between. While a Tags filter or a search is active, Images re-reads its loaded range in place (same query, from the first page up to the loaded count, published once), keeping the cards, the scroll position and the selection while the selected file still matches; when it no longer matches, the panel follows the collection.
This commit is contained in:
@@ -110,8 +110,11 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
onChange(state);
|
||||
}
|
||||
|
||||
// A refresh in flight (its abort controller): Load more waits for it, a reset or dispose cancels it.
|
||||
let refreshing = null;
|
||||
|
||||
async function loadMore() {
|
||||
if (disposed || state.loading || !state.hasMore) return 0;
|
||||
if (disposed || state.loading || !state.hasMore || refreshing) return 0;
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
const cursor = state.cursor;
|
||||
@@ -148,11 +151,53 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the loaded range of the same query (a write may have changed which files match) and publish the new
|
||||
* collection once, with the hint `{replaced: true}`; the current items stay shown until then. A failure, a reset
|
||||
* or a dispose leaves the collection as it was. Resolves to the number of items now loaded (0 when abandoned).
|
||||
*/
|
||||
async function refresh() {
|
||||
if (disposed || state.loading) return 0;
|
||||
refreshing?.abort();
|
||||
const request = refreshing = new AbortController();
|
||||
const target = Math.max(state.loaded, 1);
|
||||
const items = [], keys = new Set();
|
||||
let cursor = null, total = 0, hasMore = true, positiveCount = null;
|
||||
try {
|
||||
while (items.length < target && hasMore) {
|
||||
const parameters = new URLSearchParams(query);
|
||||
if (cursor !== null) parameters.set('cursor', cursor);
|
||||
const page = await readPage({parameters, signal:request.signal});
|
||||
if (disposed || request !== refreshing) return 0;
|
||||
validatePage(page, cursor);
|
||||
for (const item of page.images) {
|
||||
const key = imageKey(item);
|
||||
if (keys.has(key)) continue;
|
||||
keys.add(key);
|
||||
items.push(Object.freeze({ ...item }));
|
||||
}
|
||||
if (cursor === null) positiveCount = isCount(page.positive_count) ? page.positive_count : null;
|
||||
({total, next_cursor: cursor, has_more: hasMore} = page);
|
||||
}
|
||||
} catch {
|
||||
if (request === refreshing) refreshing = null;
|
||||
return 0;
|
||||
}
|
||||
refreshing = null;
|
||||
seen = keys;
|
||||
state = Object.freeze({ ...state, items: Object.freeze(items), total, cursor, hasMore, loading: false, error: null,
|
||||
loaded: items.length, positiveCount });
|
||||
onChange(state, {replaced: true});
|
||||
return items.length;
|
||||
}
|
||||
|
||||
async function reset(filters = {}, {load = true} = {}) {
|
||||
if (disposed) return 0;
|
||||
const nextQuery = queryFor(filters, batchSize);
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
refreshing?.abort();
|
||||
refreshing = null;
|
||||
query = nextQuery;
|
||||
seen = new Set();
|
||||
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
|
||||
@@ -164,6 +209,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
get state() { return state; },
|
||||
reset,
|
||||
loadMore,
|
||||
refresh,
|
||||
/** Patch one loaded item (a favorite toggle); false when it is not loaded or the stream is gone. */
|
||||
updateItem(key, patch) {
|
||||
const next = withItem(state, key, patch);
|
||||
@@ -178,6 +224,8 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
disposed = true;
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
refreshing?.abort();
|
||||
refreshing = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -410,9 +410,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
else if (isTagButton(action)) openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
|
||||
else if (action) inspect(image);
|
||||
});
|
||||
// A saved tag set re-reads the selected file's details (its inspector envelope carries the image tags).
|
||||
scope.cleanup(onImageTagsSaved(({target}) => {
|
||||
if (selection === `${target.root_index}:${target.path}`) inspection.retry();
|
||||
// A saved tag set re-reads the selected file's details (its inspector envelope carries the image tags) without a
|
||||
// loading flash. Under a Tags filter or a search the saved file may now match or stop matching: the loaded range
|
||||
// is re-read in place first, keeping the scroll and the selection while it still matches.
|
||||
scope.cleanup(onImageTagsSaved(async ({target}) => {
|
||||
const key = `${target.root_index}:${target.path}`;
|
||||
if (!embedded && !recovering && (filters.tags.length || filters.q)) await stream.refresh();
|
||||
if (!scope.signal.aborted && selection === key) inspection.refresh();
|
||||
}));
|
||||
|
||||
function renderVisible() {
|
||||
@@ -548,14 +552,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// The concept's end of the collection (app.js updateImageContinuation).
|
||||
return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`;
|
||||
}
|
||||
function renderState(next, {patch} = {}) {
|
||||
function renderState(next, {patch, replaced = false} = {}) {
|
||||
const previous = state;
|
||||
// A patched item (a favorite) keeps every card's size and place; setFavorite repaints its card.
|
||||
const changed = !patch && state?.items !== next.items;
|
||||
if (changed) {
|
||||
if (!next.items.length) itemIndexes.clear();
|
||||
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
|
||||
// A refreshed collection (`replaced`) is indexed again from its start; a loaded page only appends.
|
||||
if (!next.items.length || replaced) itemIndexes.clear();
|
||||
for (let index = replaced ? 0 : state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
|
||||
}
|
||||
// The selected file left the refreshed result: the panel follows the collection, as after a reload.
|
||||
if (replaced && selection !== null && !itemIndexes.has(selection)) dropSelection();
|
||||
state = next;
|
||||
if (!embedded) {
|
||||
setText(count, countLine(next));
|
||||
@@ -564,7 +571,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}
|
||||
if (changed) updateLayout();
|
||||
// The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection.
|
||||
if (!embedded && changed && previous?.loaded && next.loaded > previous.loaded) {
|
||||
if (!embedded && changed && !replaced && previous?.loaded && next.loaded > previous.loaded) {
|
||||
const added = next.loaded - previous.loaded;
|
||||
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
|
||||
}
|
||||
@@ -624,6 +631,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
|
||||
toolbar?.setActiveFilters(activeRefinements(filters));
|
||||
}
|
||||
function dropSelection() {
|
||||
inspection.clear();
|
||||
detailInspector?.dispose(); detailInspector = null;
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
details.replaceChildren(noSelection());
|
||||
selection = null;
|
||||
}
|
||||
function reset() {
|
||||
mediaViewer.stopSlideshow();
|
||||
main.querySelector('#viewer-dialog[open]')?.close();
|
||||
|
||||
@@ -317,11 +317,12 @@ export const rootedFileAdapter = Object.freeze({
|
||||
export function createInspectionLoader(api, onChange, { extractFacts, adapter = rootedFileAdapter } = {}) {
|
||||
let request = null, disposed = false;
|
||||
return {
|
||||
async load(image) {
|
||||
/** `quiet`: publish only the answer, so the shown record stays until it lands (a re-read after a write). */
|
||||
async load(image, { quiet = false } = {}) {
|
||||
if (disposed) return;
|
||||
request?.abort();
|
||||
const current = request = new AbortController();
|
||||
onChange({ loading: true });
|
||||
if (!quiet) onChange({ loading: true });
|
||||
try {
|
||||
const result = await api.get(adapter.inspectionPath(image), { signal: current.signal });
|
||||
if (disposed || request !== current) return;
|
||||
@@ -354,8 +355,8 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
|
||||
}
|
||||
const makeLoader = () => createInspectionLoader(api, publish, { extractFacts, adapter });
|
||||
let loader = makeLoader();
|
||||
function read(image) {
|
||||
pending = loader.load(image);
|
||||
function read(image, options) {
|
||||
pending = loader.load(image, options);
|
||||
return pending;
|
||||
}
|
||||
return {
|
||||
@@ -379,6 +380,11 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
|
||||
if (disposed || !hasAttempt || state.refreshRequired) return;
|
||||
return read(attempted);
|
||||
},
|
||||
/** Re-read the selected file keeping the shown record until the answer (a retry when none is shown). */
|
||||
refresh() {
|
||||
if (disposed || !hasAttempt || state.refreshRequired) return;
|
||||
return read(attempted, { quiet: Boolean(state.record) && !state.loading && !state.error });
|
||||
},
|
||||
clear() {
|
||||
if (disposed) return;
|
||||
loader.dispose(); loader = makeLoader(); current = null; pending = null; attempted = undefined; hasAttempt = false;
|
||||
|
||||
Reference in New Issue
Block a user