quantvat / src /static /js /chart_browser.js
heisbuba's picture
Rename src/static/js/image_browser.js to src/static/js/chart_browser.js
6b13cfb verified
Raw History Blame
41.2 kB
(() => {
const CB = window.CB;
const $ = (id) => document.getElementById(id);
const els = {
picker: $('cb-picker'),
tabRoot: $('cb-tab-root'),
tabShared: $('cb-tab-shared'),
backViewer: $('cb-back-viewer'),
up: $('cb-up'),
pathName: $('cb-path-name'),
openHere: $('cb-open-here'),
folderList: $('cb-folder-list'),
pickerStatus: $('cb-picker-status'),
viewer: $('cb-viewer'),
folderTitle: $('cb-folder-title'),
sort: $('cb-sort'),
changeFolder: $('cb-change-folder'),
stage: $('cb-stage'),
slide: $('cb-slide'),
img: $('cb-img'),
loading: $('cb-loading'),
error: $('cb-error'),
errorText: $('cb-error-text'),
retry: $('cb-retry'),
empty: $('cb-empty'),
prev: $('cb-prev'),
next: $('cb-next'),
jump: $('cb-jump'),
total: $('cb-total'),
filename: $('cb-filename'),
meta: $('cb-meta'),
play: $('cb-play'),
zoom: $('cb-zoom'),
fullscreen: $('cb-fullscreen'),
download: $('cb-download'),
driveLink: $('cb-drive-link'),
preload: $('cb-preload'),
preloadLabel: $('cb-preload-label'),
refresh: $('cb-refresh'),
clear: $('cb-clear'),
toast: $('cb-toast'),
};
const SLIDESHOW_MS = 4000;
const BLOB_CACHE_MAX = 30;
const SLIDESHOW_WAIT_MAX_MS = 60000;
// Prefetch window adapts to the connection: on 2G/3G/Save-Data we keep it
// small and sequential so the image being viewed gets the whole pipe.
function netProfile() {
const c = navigator.connection || {};
const slow = c.saveData || /(^|-)2g$|3g/.test(c.effectiveType || '');
return slow ? { batch: 3, concurrency: 1 } : { batch: 8, concurrency: 2 };
}
const MAX_SCALE = 5;
const ZOOM_TAP_SCALE = 2.5;
const SWIPE_MIN_PX = 60;
const ANIM_MS = 200;
const state = {
tab: 'root',
pickerFolder: null,
folder: null,
images: [],
cached: new Set(),
index: 0,
sort: 'name',
scale: 1,
tx: 0,
ty: 0,
playing: false,
timer: null,
animating: false,
pickSeq: 0,
listSeq: 0,
loadSeq: 0,
preloadSeq: 0,
preloading: false,
autoRefreshed: false,
blobCache: new Map(),
inflight: new Map(),
prefetchRun: 0,
fullFor: null,
fullUrl: null,
toastTimer: null,
idleTimer: null,
revalidating: null,
};
const imageUrl = (id, extra) => {
const item = state.images.find((i) => i.id === id);
const v = item && item.modified ? `v=${encodeURIComponent(item.modified)}` : '';
const qs = [v, extra].filter(Boolean).join('&');
return CB.urls.image.replace('FILE_ID', encodeURIComponent(id)) + (qs ? `?${qs}` : '');
};
const current = () => state.images[state.index] || null;
class ApiError extends Error {
constructor(message, needsConnect) {
super(message);
this.needsConnect = !!needsConnect;
}
}
async function api(url, options) {
let res;
try {
res = await fetch(url, options);
} catch (e) {
throw new ApiError('Network error. Check your connection and try again.');
}
let data = null;
try {
data = await res.json();
} catch (e) {
data = null;
}
if (!res.ok || !data || data.status !== 'success') {
const message = (data && data.message) || `Request failed (${res.status})`;
throw new ApiError(message, data && data.needs_connect);
}
return data;
}
function make(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function toast(message) {
els.toast.textContent = message;
els.toast.hidden = false;
clearTimeout(state.toastTimer);
state.toastTimer = setTimeout(() => { els.toast.hidden = true; }, 3500);
}
function setPickerStatus(message, isError) {
els.pickerStatus.textContent = message || '';
els.pickerStatus.classList.toggle('is-error', !!isError);
}
function appendReconnect(container) {
const link = make('a', 'cb-btn cb-btn-primary', 'RECONNECT GOOGLE DRIVE');
link.href = CB.urls.connect;
container.appendChild(document.createElement('br'));
container.appendChild(link);
}
// ---------- Instant-open cache (per tab session) ----------
// The last folder listing and folder tree are kept in sessionStorage so
// coming back from the dashboard paints with zero network wait; Drive is
// re-checked in the background. sessionStorage dies with the tab, so
// nothing lingers on shared machines.
const LIST_PREFIX = 'cb:list:';
const DIR_PREFIX = 'cb:dir:';
const listKey = (folderId, sort) => `${LIST_PREFIX}${folderId}:${sort}`;
function readLocal(key) {
try {
const raw = sessionStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
function writeLocal(key, value) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch (e) {
// quota or disabled storage: the instant path just won't be used
}
}
function pruneLists(keepKey) {
try {
const keys = [];
for (let i = 0; i < sessionStorage.length; i++) {
const k = sessionStorage.key(i);
if (k && k.startsWith(LIST_PREFIX) && k !== keepKey) keys.push(k);
}
keys.slice(0, Math.max(0, keys.length - 2)).forEach((k) => sessionStorage.removeItem(k));
} catch (e) {
// ignore
}
}
const listSignature = (images) => images.map((i) => `${i.id}:${i.modified || ''}`).join('|');
// ---------- Folder picker ----------
function showPicker() {
stopSlideshow();
els.viewer.hidden = true;
els.picker.hidden = false;
els.backViewer.hidden = !state.folder;
}
function setActiveTab(folderId) {
if (folderId === 'root') state.tab = 'root';
else if (folderId === 'shared') state.tab = 'shared';
els.tabRoot.classList.toggle('is-active', state.tab === 'root');
els.tabShared.classList.toggle('is-active', state.tab === 'shared');
}
function renderFolders(folders) {
els.folderList.replaceChildren();
folders.forEach((f) => {
const item = make('div', 'cb-folder-item');
const open = make('button', 'cb-folder-row');
open.type = 'button';
open.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>';
open.appendChild(make('span', '', f.name));
open.addEventListener('click', () => loadPicker(f.id));
const view = make('button', 'cb-btn cb-btn-ghost', 'VIEW');
view.type = 'button';
view.addEventListener('click', () => openFolder(f.id));
item.append(open, view);
els.folderList.appendChild(item);
});
}
function paintPicker(data, notice) {
state.pickerFolder = data.folder;
setActiveTab(data.folder.id);
els.pathName.textContent = data.folder.name;
els.up.disabled = !data.folder.parent;
els.openHere.disabled = data.folder.id === 'shared';
renderFolders(data.folders);
setPickerStatus(notice || (data.folders.length ? '' : 'No subfolders here.'), !!notice);
}
async function loadPicker(parentId, notice) {
const seq = ++state.pickSeq;
const key = DIR_PREFIX + parentId;
const local = readLocal(key);
if (local && local.folder && local.folders) {
// show the last known folders immediately, refresh underneath
paintPicker(local, notice);
} else {
setPickerStatus('Loading folders…');
els.folderList.replaceChildren();
}
try {
const data = await api(`${CB.urls.folders}?parent=${encodeURIComponent(parentId)}`);
if (seq !== state.pickSeq) return;
writeLocal(key, { folder: data.folder, folders: data.folders });
paintPicker(data, notice);
} catch (e) {
if (seq !== state.pickSeq) return;
setPickerStatus(e.message, true);
if (e.needsConnect) appendReconnect(els.pickerStatus);
}
}
// ---------- Viewer ----------
function showViewer() {
els.picker.hidden = true;
els.viewer.hidden = false;
}
function fetchListing(folderId, swr) {
const q = `${CB.urls.images}?folder=${encodeURIComponent(folderId)}&sort=${encodeURIComponent(state.sort)}`;
return api(swr ? `${q}&swr=1` : q);
}
function applyListing(data, keepId, persist) {
state.preloadSeq++;
state.prefetchRun++;
state.preloading = false;
state.folder = data.folder;
state.images = data.images;
state.cached = new Set(data.cached || []);
let idx = 0;
if (keepId) {
const found = data.images.findIndex((i) => i.id === keepId);
if (found >= 0) idx = found;
}
state.index = idx;
els.folderTitle.textContent = data.folder.name;
els.folderTitle.title = data.folder.name;
if (data.truncated) toast('Showing the first 1000 images in this folder.');
if (persist) {
const key = listKey(data.folder.id, state.sort);
writeLocal(key, { folder: data.folder, images: data.images, truncated: !!data.truncated });
pruneLists(key);
}
}
async function fetchImages(folderId, keepId, swr) {
const seq = ++state.listSeq;
const data = await fetchListing(folderId, swr);
if (seq !== state.listSeq) return null;
applyListing(data, keepId, !data.stale);
return data;
}
// Re-check Drive after an instant paint. If nothing changed we leave the
// screen alone (no flicker, no lost zoom); otherwise we swap in the new
// list and stay on the same image.
function revalidate(folderId) {
const seq = state.listSeq;
const run = (async () => {
try {
const data = await fetchListing(folderId, false);
if (seq !== state.listSeq || !state.folder || state.folder.id !== data.folder.id) return;
const key = listKey(data.folder.id, state.sort);
if (listSignature(data.images) === listSignature(state.images)) {
(data.cached || []).forEach((id) => state.cached.add(id));
state.folder = data.folder;
writeLocal(key, { folder: data.folder, images: data.images, truncated: !!data.truncated });
updatePreloadLabel();
return;
}
applyListing(data, current() ? current().id : null, true);
render();
} catch (e) {
if (e.needsConnect) toast('Google Drive access expired. Please reconnect.');
} finally {
if (state.revalidating === run) state.revalidating = null;
}
})();
state.revalidating = run;
}
async function openFolder(folderId) {
setPickerStatus('Loading images…');
const local = readLocal(listKey(folderId, state.sort));
try {
if (local && local.images && local.folder) {
++state.listSeq;
applyListing(local, null, false);
state.autoRefreshed = false;
setPickerStatus('');
showViewer();
render();
revalidate(folderId);
return;
}
const data = await fetchImages(folderId, null, true);
if (!data) return;
state.autoRefreshed = false;
setPickerStatus('');
showViewer();
render();
if (data.stale) revalidate(folderId);
} catch (e) {
showPicker();
if (state.pickerFolder) {
setPickerStatus(e.message, true);
if (e.needsConnect) appendReconnect(els.pickerStatus);
} else {
loadPicker('root', `Could not open that folder: ${e.message}`);
}
}
}
async function reloadFolder(message) {
if (!state.folder) return;
const keepId = current() ? current().id : null;
try {
const data = await fetchImages(state.folder.id, keepId, false);
if (!data) return;
render();
if (message) toast(message);
} catch (e) {
toast(e.message);
}
}
function updatePreloadLabel() {
const total = state.images.length;
if (state.preloading) {
els.preloadLabel.textContent = `${state.cached.size}/${total} · Stop`;
els.preloadLabel.classList.add('is-busy');
els.preload.classList.add('is-active');
} else {
els.preloadLabel.textContent = total && state.cached.size >= total ? 'All cached' : 'Preload all';
els.preloadLabel.classList.remove('is-busy');
els.preload.classList.remove('is-active');
}
}
function render() {
const total = state.images.length;
const item = current();
els.total.textContent = String(total);
els.jump.max = String(Math.max(total, 1));
els.jump.style.width = `${String(Math.max(total, 1)).length + 0.8}ch`;
els.jump.value = total ? String(state.index + 1) : '0';
els.jump.disabled = !total;
els.prev.disabled = state.index <= 0;
els.next.disabled = state.index >= total - 1;
els.empty.hidden = total > 0;
updatePreloadLabel();
els.play.disabled = total < 2;
els.zoom.disabled = !total;
if (!item) {
els.img.classList.remove('is-ready');
els.img.removeAttribute('src');
els.filename.textContent = '';
els.meta.textContent = '';
els.loading.hidden = true;
els.error.hidden = true;
els.download.classList.add('is-disabled');
els.driveLink.classList.add('is-disabled');
return;
}
els.filename.textContent = item.name;
const dims = item.width && item.height ? `${item.width} × ${item.height}` : '';
els.meta.textContent = [dims, formatSize(item.size), formatDate(item.modified)].filter(Boolean).join(' · ');
els.download.href = imageUrl(item.id, 'download=1');
els.download.classList.remove('is-disabled');
if (item.link) {
els.driveLink.href = item.link;
els.driveLink.classList.remove('is-disabled');
} else {
els.driveLink.classList.add('is-disabled');
}
loadCurrent();
}
function cacheBlob(id, blob) {
if (state.blobCache.has(id)) return state.blobCache.get(id);
while (state.blobCache.size >= BLOB_CACHE_MAX && evictFarthest()) {
// keep the images nearest the current one; drop the farthest
}
const url = URL.createObjectURL(blob);
state.blobCache.set(id, url);
return url;
}
function peekBlob(id) {
return state.blobCache.get(id) || null;
}
// Evict by distance from the current index (not insertion order), so the
// next images we just prefetched can never be the first to go.
function evictFarthest() {
const total = state.images.length;
const shown = els.img.getAttribute('src');
let worstId = null;
let worstDist = -1;
const pos = new Map(state.images.map((it, i) => [it.id, i]));
for (const [id, url] of state.blobCache) {
if (url === shown) continue;
const i = pos.get(id);
let dist = 1e9;
if (i !== undefined) {
const fwd = (i - state.index + total) % total;
// going forward is far more likely than going back
dist = Math.min(fwd, (total - fwd) * 3);
}
if (dist > worstDist) {
worstDist = dist;
worstId = id;
}
}
if (worstId === null) return false;
URL.revokeObjectURL(state.blobCache.get(worstId));
state.blobCache.delete(worstId);
return true;
}
// Downloads an image once and stores it in the blob cache *as soon as it
// arrives*, whether or not the viewer is still waiting for it. Previously
// a slow download that finished after the user (or slideshow) had moved on
// was thrown away, so on 2G/3G nothing ever finished loading.
function fetchBlob(id) {
if (!state.inflight.has(id)) {
state.inflight.set(id, (async () => {
try {
let res = await fetch(imageUrl(id));
if (res.status === 404 && state.revalidating) {
// the server's folder index may not exist yet (it is
// rebuilt by the background re-list); wait and retry
await state.revalidating;
res = await fetch(imageUrl(id));
}
if (!res.ok) throw new Error(String(res.status));
const blob = await res.blob();
cacheBlob(id, blob);
state.cached.add(id);
updatePreloadLabel();
return blob;
} finally {
state.inflight.delete(id);
}
})());
}
return state.inflight.get(id);
}
function clearBlobCache() {
state.blobCache.forEach((url) => URL.revokeObjectURL(url));
state.blobCache.clear();
}
async function loadCurrent() {
const item = current();
if (!item) return;
const seq = ++state.loadSeq;
if (state.fullUrl) {
URL.revokeObjectURL(state.fullUrl);
state.fullUrl = null;
}
state.fullFor = null;
resetZoom();
els.error.hidden = true;
const cachedUrl = peekBlob(item.id);
if (cachedUrl) {
els.img.alt = item.name;
els.img.src = cachedUrl;
try {
await els.img.decode();
} catch (e) {
// decode can reject for formats the browser lazily decodes
}
if (seq !== state.loadSeq) return;
els.img.classList.add('is-ready');
els.loading.hidden = true;
prefetchAhead();
slideshowReady();
return;
}
els.img.classList.remove('is-ready');
const spinnerTimer = setTimeout(() => {
if (seq === state.loadSeq) els.loading.hidden = false;
}, 150);
try {
let blob;
try {
blob = await fetchBlob(item.id);
} catch (e) {
const err = new Error(e.message);
err.status = Number(e.message) || undefined;
throw err;
}
if (seq !== state.loadSeq) return;
els.img.alt = item.name;
els.img.src = cacheBlob(item.id, blob);
els.img.alt = item.name;
try {
await els.img.decode();
} catch (e) {
// decode can reject for formats the browser lazily decodes; onload path below still applies
}
if (seq !== state.loadSeq) return;
els.img.classList.add('is-ready');
prefetchAhead();
slideshowReady();
} catch (e) {
if (seq !== state.loadSeq) return;
handleLoadError(e.status);
} finally {
clearTimeout(spinnerTimer);
if (seq === state.loadSeq) els.loading.hidden = true;
}
}
async function handleLoadError(status) {
if (status === 404 && !state.autoRefreshed) {
state.autoRefreshed = true;
await reloadFolder();
return;
}
let text = 'Could not load this image.';
if (status === 401) text = 'Google Drive access expired.';
else if (status === 404) text = 'This image is no longer available. Try Refresh.';
else if (status === 413) text = 'This image is too large to preview. Use Download or Open in Drive.';
els.errorText.textContent = text;
els.retry.hidden = status === 413;
els.error.hidden = false;
if (status === 401 && !els.error.querySelector('a')) appendReconnect(els.error);
}
async function prefetchAhead() {
const run = ++state.prefetchRun;
const total = state.images.length;
if (total < 2) return;
const { batch, concurrency } = netProfile();
const ids = [];
for (let step = 1; step <= batch; step++) {
const item = state.images[(state.index + step) % total];
if (!item) break;
if (state.blobCache.has(item.id)) continue;
ids.push(item.id);
}
if (!ids.length) return;
let cursor = 0;
const worker = async () => {
while (run === state.prefetchRun && cursor < ids.length) {
if (document.hidden) {
await new Promise((r) => setTimeout(r, 1000));
continue;
}
try {
await fetchBlob(ids[cursor++]);
} catch (e) {
// prefetch is best-effort; loadCurrent handles real errors
}
}
};
await Promise.all(Array.from({ length: concurrency }, worker));
}
// ---------- Navigation ----------
function animateTo(next, dir) {
const width = els.stage.clientWidth || 1;
if (state.animating) {
state.index = next;
render();
return;
}
state.animating = true;
state.index = next;
els.slide.classList.add('is-animating');
els.slide.style.transform = `translateX(${-dir * width}px)`;
setTimeout(() => {
els.slide.classList.remove('is-animating');
els.slide.style.transform = `translateX(${dir * width}px)`;
render();
void els.slide.offsetWidth;
els.slide.classList.add('is-animating');
els.slide.style.transform = 'translateX(0)';
setTimeout(() => {
els.slide.classList.remove('is-animating');
els.slide.style.transform = '';
state.animating = false;
}, ANIM_MS);
}, ANIM_MS);
}
function go(delta, wrap) {
const total = state.images.length;
if (total < 2) return;
let next = state.index + delta;
if (wrap) next = (next + total) % total;
else if (next < 0 || next >= total) return;
if (next === state.index) return;
animateTo(next, delta > 0 ? 1 : -1);
if (state.playing) clearTimeout(state.timer);
}
function jumpTo(n) {
const total = state.images.length;
if (!total || isNaN(n)) {
els.jump.value = String(state.index + 1);
return;
}
state.index = Math.min(Math.max(n, 1), total) - 1;
render();
}
// ---------- Slideshow ----------
// The timer is a *dwell* timer: it starts when the current image is
// actually on screen, and the slideshow only advances once the next image
// is already in memory (or SLIDESHOW_WAIT_MAX_MS has passed). On a slow
// link the slideshow therefore pauses on the picture instead of racing
// through spinners.
function startSlideshow() {
clearTimeout(state.timer);
state.playing = true;
els.play.classList.add('is-active');
if (els.img.classList.contains('is-ready')) scheduleAdvance();
}
function nextItem() {
const total = state.images.length;
return total > 1 ? state.images[(state.index + 1) % total] : null;
}
function scheduleAdvance() {
clearTimeout(state.timer);
if (!state.playing) return;
const shownAt = Date.now();
const tick = () => {
if (!state.playing) return;
const next = nextItem();
const waited = Date.now() - shownAt - SLIDESHOW_MS;
const ready = !next || state.blobCache.has(next.id) || waited > SLIDESHOW_WAIT_MAX_MS;
if (state.scale === 1 && !document.hidden && ready && !state.animating) {
go(1, true);
} else {
if (next && !state.blobCache.has(next.id)) fetchBlob(next.id).then(() => {}, () => {});
state.timer = setTimeout(tick, 400);
}
};
state.timer = setTimeout(tick, SLIDESHOW_MS);
}
function slideshowReady() {
if (state.playing) scheduleAdvance();
}
function stopSlideshow() {
clearTimeout(state.timer);
state.timer = null;
state.playing = false;
els.play.classList.remove('is-active');
}
function toggleSlideshow() {
if (state.playing) stopSlideshow();
else if (state.images.length > 1) startSlideshow();
}
// ---------- Zoom & pan ----------
function clampPan() {
const maxX = Math.max(0, (els.img.clientWidth * state.scale - els.stage.clientWidth) / 2);
const maxY = Math.max(0, (els.img.clientHeight * state.scale - els.stage.clientHeight) / 2);
state.tx = Math.min(Math.max(state.tx, -maxX), maxX);
state.ty = Math.min(Math.max(state.ty, -maxY), maxY);
}
function applyTransform() {
if (state.scale === 1) {
state.tx = 0;
state.ty = 0;
els.img.style.transform = '';
} else {
clampPan();
els.img.style.transform = `translate(${state.tx}px, ${state.ty}px) scale(${state.scale})`;
}
els.stage.classList.toggle('is-zoomed', state.scale > 1);
els.zoom.classList.toggle('is-active', state.scale > 1);
}
function resetZoom() {
state.scale = 1;
applyTransform();
}
// Views use a ~1600px preview. The first time the user zooms in we fetch
// the original in the background and swap it in so text stays sharp.
async function upgradeToFull() {
const item = current();
if (!item || state.fullFor === item.id || item.size < 250 * 1024 || item.mime === 'image/gif') return;
state.fullFor = item.id;
const seq = state.loadSeq;
try {
const res = await fetch(imageUrl(item.id, 'size=full'));
if (!res.ok) return;
const blob = await res.blob();
if (seq !== state.loadSeq) return;
const url = URL.createObjectURL(blob);
if (state.fullUrl) URL.revokeObjectURL(state.fullUrl);
state.fullUrl = url;
els.img.src = url;
} catch (e) {
state.fullFor = null;
}
}
function zoomAt(newScale, clientX, clientY) {
const s = Math.min(Math.max(newScale, 1), MAX_SCALE);
const rect = els.stage.getBoundingClientRect();
const vx = clientX - (rect.left + rect.width / 2);
const vy = clientY - (rect.top + rect.height / 2);
const ratio = s / state.scale;
state.tx = vx - (vx - state.tx) * ratio;
state.ty = vy - (vy - state.ty) * ratio;
const wasFit = state.scale === 1;
state.scale = s;
applyTransform();
if (wasFit && s > 1) upgradeToFull();
}
function stageCenter() {
const rect = els.stage.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function canZoom() {
return !!current() && els.img.classList.contains('is-ready');
}
function toggleZoom(clientX, clientY) {
if (!canZoom()) return;
if (state.scale > 1) {
resetZoom();
} else {
const c = stageCenter();
zoomAt(ZOOM_TAP_SCALE, clientX === undefined ? c.x : clientX, clientY === undefined ? c.y : clientY);
}
}
// ---------- Pointer gestures ----------
const pointers = new Map();
let drag = null;
let pinch = null;
let lastTap = { time: 0, x: 0, y: 0 };
function pointerDistance() {
const [a, b] = Array.from(pointers.values());
return Math.hypot(a.x - b.x, a.y - b.y);
}
function pointerMidpoint() {
const [a, b] = Array.from(pointers.values());
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function onPointerDown(e) {
if (e.target.closest('.cb-nav, .cb-counter, .cb-error')) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
els.stage.focus({ preventScroll: true });
els.stage.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 1) {
drag = { startX: e.clientX, startY: e.clientY, lastX: e.clientX, lastY: e.clientY, mode: null, time: Date.now() };
} else if (pointers.size === 2) {
drag = null;
pinch = { dist: pointerDistance(), scale0: state.scale };
}
}
function onPointerMove(e) {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pinch && pointers.size >= 2) {
const mid = pointerMidpoint();
const target = pinch.scale0 * (pointerDistance() / pinch.dist);
zoomAt(target, mid.x, mid.y);
return;
}
if (!drag) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
if (state.scale > 1) {
state.tx += e.clientX - drag.lastX;
state.ty += e.clientY - drag.lastY;
drag.lastX = e.clientX;
drag.lastY = e.clientY;
drag.mode = 'pan';
els.stage.classList.add('is-dragging');
applyTransform();
return;
}
if (drag.mode === null && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) drag.mode = 'swipe';
if (drag.mode === 'swipe' && !state.animating) {
const atStart = state.index === 0 && dx > 0;
const atEnd = state.index === state.images.length - 1 && dx < 0;
els.slide.style.transform = `translateX(${atStart || atEnd ? dx * 0.3 : dx}px)`;
}
}
function onPointerEnd(e) {
if (!pointers.has(e.pointerId)) return;
pointers.delete(e.pointerId);
els.stage.classList.remove('is-dragging');
if (pinch) {
if (pointers.size < 2) pinch = null;
if (pointers.size === 0) drag = null;
return;
}
if (!drag) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
const finished = drag;
drag = null;
if (finished.mode === 'swipe' && e.type === 'pointerup') {
const dir = dx < 0 ? 1 : -1;
const target = state.index + dir;
const valid = target >= 0 && target < state.images.length;
if (Math.abs(dx) >= SWIPE_MIN_PX && valid) {
animateTo(target, dir);
if (state.playing) clearTimeout(state.timer);
} else {
els.slide.classList.add('is-animating');
els.slide.style.transform = 'translateX(0)';
setTimeout(() => {
els.slide.classList.remove('is-animating');
els.slide.style.transform = '';
}, ANIM_MS);
}
return;
}
const moved = Math.hypot(dx, dy);
if (e.type === 'pointerup' && moved < 10 && Date.now() - finished.time < 300) {
const now = Date.now();
if (now - lastTap.time < 320 && Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) < 30) {
toggleZoom(e.clientX, e.clientY);
lastTap = { time: 0, x: 0, y: 0 };
} else {
lastTap = { time: now, x: e.clientX, y: e.clientY };
}
}
}
function onWheel(e) {
if (!canZoom()) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
zoomAt(state.scale * factor, e.clientX, e.clientY);
}
// ---------- Preload & cache ----------
async function preloadAll() {
if (state.preloading) {
state.preloadSeq++;
state.preloading = false;
updatePreloadLabel();
return;
}
const queue = state.images.filter((i) => !state.cached.has(i.id));
if (!queue.length) {
toast('Every image in this folder is already cached.');
return;
}
const seq = ++state.preloadSeq;
state.preloading = true;
updatePreloadLabel();
let failed = 0;
let cursor = 0;
const worker = async () => {
while (seq === state.preloadSeq && cursor < queue.length) {
const item = queue[cursor++];
try {
await fetchBlob(item.id);
} catch (e) {
failed++;
}
}
};
await Promise.all([worker(), worker()]);
if (seq !== state.preloadSeq) return;
state.preloading = false;
updatePreloadLabel();
toast(failed ? `Cached with ${failed} failure${failed === 1 ? '' : 's'}.` : 'All images cached.');
}
async function clearCache() {
try {
const data = await api(CB.urls.clearCache, {
method: 'POST',
headers: { 'X-CSRFToken': CB.csrf },
});
state.preloadSeq++;
state.preloading = false;
state.cached.clear();
state.inflight.clear();
clearBlobCache();
updatePreloadLabel();
toast(`Cleared ${data.freed_mb} MB of temporary images.`);
} catch (e) {
toast(e.message);
}
}
// ---------- Fullscreen ----------
const CHROME_IDLE_MS = 2800;
function wakeChrome() {
els.viewer.classList.remove('is-idle');
clearTimeout(state.idleTimer);
if (!document.fullscreenElement) return;
state.idleTimer = setTimeout(() => els.viewer.classList.add('is-idle'), CHROME_IDLE_MS);
}
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if (els.viewer.requestFullscreen) {
els.viewer.requestFullscreen().catch(() => toast('Fullscreen is not available here.'));
}
}
// ---------- Wiring ----------
function onKeyDown(e) {
if (els.viewer.hidden) return;
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
switch (e.key) {
case 'ArrowLeft':
go(-1);
break;
case 'ArrowRight':
go(1);
break;
case 'Home':
if (state.images.length) jumpTo(1);
break;
case 'End':
if (state.images.length) jumpTo(state.images.length);
break;
case ' ':
if (tag === 'BUTTON' || tag === 'A') return;
toggleSlideshow();
break;
case 'f':
case 'F':
toggleFullscreen();
break;
case '+':
case '=': {
if (!canZoom()) break;
const c = stageCenter();
zoomAt(state.scale * 1.4, c.x, c.y);
break;
}
case '-': {
if (!canZoom()) break;
const c = stageCenter();
zoomAt(state.scale / 1.4, c.x, c.y);
break;
}
case 'Escape':
if (state.scale > 1) resetZoom();
else if (state.playing) stopSlideshow();
break;
default:
return;
}
e.preventDefault();
}
function init() {
els.tabRoot.addEventListener('click', () => { state.tab = 'root'; loadPicker('root'); });
els.tabShared.addEventListener('click', () => { state.tab = 'shared'; loadPicker('shared'); });
els.up.addEventListener('click', () => {
if (state.pickerFolder && state.pickerFolder.parent) loadPicker(state.pickerFolder.parent);
});
els.openHere.addEventListener('click', () => {
if (state.pickerFolder) openFolder(state.pickerFolder.id);
});
els.backViewer.addEventListener('click', () => {
els.picker.hidden = true;
els.viewer.hidden = false;
});
els.changeFolder.addEventListener('click', () => {
showPicker();
const start = state.folder && state.folder.parent ? state.folder.parent : 'root';
loadPicker(start);
});
els.sort.addEventListener('change', () => {
state.sort = els.sort.value;
reloadFolder();
});
els.prev.addEventListener('click', () => go(-1));
els.next.addEventListener('click', () => go(1));
els.retry.addEventListener('click', () => {
els.error.hidden = true;
loadCurrent();
});
els.jump.addEventListener('focus', () => els.jump.select());
els.jump.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
jumpTo(parseInt(els.jump.value, 10));
els.jump.blur();
} else if (e.key === 'Escape') {
els.jump.value = String(state.index + 1);
els.jump.blur();
}
});
els.jump.addEventListener('change', () => jumpTo(parseInt(els.jump.value, 10)));
els.play.addEventListener('click', toggleSlideshow);
els.zoom.addEventListener('click', () => toggleZoom());
els.fullscreen.addEventListener('click', toggleFullscreen);
els.preload.addEventListener('click', preloadAll);
els.refresh.addEventListener('click', () => reloadFolder('Folder refreshed.'));
els.clear.addEventListener('click', clearCache);
if (!els.viewer.requestFullscreen) els.fullscreen.hidden = true;
document.addEventListener('fullscreenchange', () => {
els.fullscreen.classList.toggle('is-active', !!document.fullscreenElement);
wakeChrome();
applyTransform();
});
els.stage.addEventListener('pointerdown', onPointerDown);
els.stage.addEventListener('pointermove', onPointerMove);
els.stage.addEventListener('pointerup', onPointerEnd);
els.stage.addEventListener('pointercancel', onPointerEnd);
els.stage.addEventListener('wheel', onWheel, { passive: false });
els.viewer.addEventListener('pointermove', wakeChrome);
els.viewer.addEventListener('pointerdown', wakeChrome);
document.addEventListener('keydown', (e) => { if (document.fullscreenElement) wakeChrome(); });
document.addEventListener('keydown', onKeyDown);
window.addEventListener('resize', applyTransform);
window.addEventListener('beforeunload', clearBlobCache);
const last = CB.lastFolder;
if (last && last.id) {
els.picker.hidden = false;
openFolder(last.id);
} else {
showPicker();
loadPicker('root');
}
}
init();
})();