* feat(inspector): expose bounded physical file evidence * fix(inspector): distinguish ambiguous saved associations * feat(inspector): derive conservative generation facts * fix(inspector): guard fact ancestry and inspection state * feat(inspector): share complete file details and exports * fix(inspector): preserve fact states and touch targets * feat(viewer): navigate the loaded media collection Refs #216 * fix(viewer): refit after current stage geometry changes Ref #216. Observe the current media stage after readiness and wrapped title changes without rebuilding focused controls or resetting transforms at unchanged dimensions. * feat(viewer): add lifecycle-safe slideshow and media preferences * fix(viewer): resume gallery continuation after close (#216) * fix(images): guard source recovery and wrap viewer actions Clear stale root and folder intent, cancel old pages, and wait for current source discovery before loading a refreshed collection. Preserve safe retry through failed, superseded, and disposed discovery. Wrap narrow viewer footer actions and wait for measured gallery viewport settling in responsive checks. Refs #216; addresses PR #224 review. * test(viewer): await gallery layout after viewport changes Require actual inner, root client, and document widths to agree after each responsive-case viewport and CSS zoom transition. Keep the existing exact frame, overflow, focus, and coarse target assertions unchanged. Observed PR #224 CI initial mobile resize at537px before pending card relayout settled to356px. Refs #216.
141 lines
6.3 KiB
JavaScript
141 lines
6.3 KiB
JavaScript
/** Identity is the source and relative path; content hashes may be shared by files. */
|
|
export function imageKey(image) {
|
|
return `${image.root_index}:${image.path}`;
|
|
}
|
|
|
|
function queryFor(filters, batchSize) {
|
|
const query = new URLSearchParams({ mode: 'workspace', limit: String(batchSize) });
|
|
for (const key of ['q', 'subfolder']) {
|
|
const value = filters[key];
|
|
if (value !== undefined && typeof value !== 'string') throw new TypeError('Invalid image filter');
|
|
if (value) query.set(key, value);
|
|
}
|
|
const root = filters.root;
|
|
if (root !== undefined && root !== 'all' && root !== '') {
|
|
if (!/^(0|[1-9]\d*)$/.test(String(root)) || !Number.isSafeInteger(Number(root))) {
|
|
throw new TypeError('Invalid root filter');
|
|
}
|
|
query.set('root', String(root));
|
|
}
|
|
const media = filters.media ?? 'all';
|
|
if (!['all', 'image', 'video'].includes(media)) throw new TypeError('Invalid media filter');
|
|
if (media !== 'all') query.set('media', media);
|
|
const sort = filters.sort ?? 'newest';
|
|
if (!['newest', 'oldest', 'name', 'name-desc'].includes(sort)) throw new TypeError('Invalid sort filter');
|
|
query.set('sort', sort);
|
|
return query.toString();
|
|
}
|
|
|
|
function validatePage(page, cursor) {
|
|
if (!page || !Array.isArray(page.images) || !Number.isSafeInteger(page.total) || page.total < 0 ||
|
|
typeof page.has_more !== 'boolean' ||
|
|
!(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) ||
|
|
page.has_more !== (page.next_cursor !== null)) {
|
|
throw new TypeError('Invalid image page');
|
|
}
|
|
if (page.has_more && page.next_cursor === cursor) throw new TypeError('Image cursor did not advance');
|
|
for (const item of page.images) {
|
|
if (!item || typeof item.path !== 'string' || !item.path ||
|
|
!Number.isSafeInteger(item.root_index) || item.root_index < 0) {
|
|
throw new TypeError('Invalid image identity');
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Keep metadata for backward scrolling; the view bounds rendered media independently. */
|
|
export function createImageStream({ api, onChange = () => {}, batchSize = 40, restore, fetchPage }) {
|
|
if (fetchPage !== undefined && typeof fetchPage !== 'function') throw new TypeError('Invalid image page adapter');
|
|
if (!fetchPage && typeof api?.get !== 'function') throw new TypeError('An API client is required');
|
|
const readPage = fetchPage || (({parameters, signal}) => api.get(`/prompt_manager/images/output?${parameters}`, {signal}));
|
|
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 200) {
|
|
throw new TypeError('Invalid image batch size');
|
|
}
|
|
let state = Object.freeze({ items: Object.freeze([]), total: 0, cursor: null,
|
|
hasMore: true, loading: false, error: null, loaded: 0 });
|
|
let query = queryFor({}, batchSize);
|
|
let controller = null;
|
|
let disposed = false;
|
|
let seen = new Set();
|
|
if (restore !== undefined) {
|
|
if (!restore || !restore.filters || typeof restore.filters !== 'object' || Array.isArray(restore.filters) || !restore.state) {
|
|
throw new TypeError('Invalid restored image state');
|
|
}
|
|
query = queryFor(restore.filters, batchSize);
|
|
const saved = restore.state;
|
|
const pristine = Array.isArray(saved.items) && saved.items.length === 0 && saved.total === 0 &&
|
|
saved.cursor === null && saved.hasMore === true;
|
|
validatePage({ images: saved.items, total: saved.total, next_cursor: saved.cursor,
|
|
has_more: pristine ? false : saved.hasMore });
|
|
seen = new Set(saved.items.map(imageKey));
|
|
if (seen.size !== saved.items.length) throw new TypeError('Duplicate restored image identity');
|
|
const items = Object.isFrozen(saved.items) && saved.items.every(Object.isFrozen) ? saved.items :
|
|
Object.freeze(saved.items.map(item => Object.freeze({ ...item })));
|
|
state = Object.freeze({ items, total: saved.total, cursor: saved.cursor, hasMore: saved.hasMore,
|
|
loading: false, error: null, loaded: items.length });
|
|
}
|
|
|
|
function publish(update) {
|
|
state = Object.freeze({ ...state, ...update });
|
|
onChange(state);
|
|
}
|
|
|
|
async function loadMore() {
|
|
if (disposed || state.loading || !state.hasMore) return 0;
|
|
const request = new AbortController();
|
|
controller = request;
|
|
const cursor = state.cursor;
|
|
publish({ loading: true, error: null });
|
|
const parameters = new URLSearchParams(query);
|
|
if (cursor !== null) parameters.set('cursor', cursor);
|
|
try {
|
|
const page = await readPage({parameters, signal:request.signal});
|
|
if (disposed || request !== controller) return 0;
|
|
validatePage(page, cursor);
|
|
const incoming = [];
|
|
const batchKeys = new Set();
|
|
for (const item of page.images) {
|
|
const key = imageKey(item);
|
|
if (seen.has(key) || batchKeys.has(key)) continue;
|
|
batchKeys.add(key);
|
|
incoming.push(Object.freeze({ ...item }));
|
|
}
|
|
for (const key of batchKeys) seen.add(key);
|
|
const items = incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items;
|
|
controller = null;
|
|
publish({ items, total: page.total, cursor: page.next_cursor, hasMore: page.has_more,
|
|
loading: false, error: null, loaded: items.length });
|
|
return incoming.length;
|
|
} catch (error) {
|
|
if (!disposed && request === controller) {
|
|
controller = null;
|
|
publish({ loading: false, error });
|
|
}
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async function reset(filters = {}, {load = true} = {}) {
|
|
if (disposed) return 0;
|
|
const nextQuery = queryFor(filters, batchSize);
|
|
controller?.abort();
|
|
controller = null;
|
|
query = nextQuery;
|
|
seen = new Set();
|
|
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
|
|
loading: false, error: null, loaded: 0 });
|
|
return load ? loadMore() : 0;
|
|
}
|
|
|
|
return {
|
|
get state() { return state; },
|
|
reset,
|
|
loadMore,
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
controller?.abort();
|
|
controller = null;
|
|
},
|
|
};
|
|
}
|