feat: visionneuse d'images - navigation fluide, image ajustee au cadre, fleches laterales et pellicule persistante #111
This commit is contained in:
+178
-42
@@ -548,13 +548,10 @@ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".
|
||||
const IMAGE_ZOOM_MIN = 0.1;
|
||||
const IMAGE_ZOOM_MAX = 8;
|
||||
let _imageViewerCleanup = null;
|
||||
// BUG-072 — the lightbox and metadata panel states must survive the re-render
|
||||
// triggered by ←/→ navigation (openFile → renderFile → renderImageViewer).
|
||||
// Reset as soon as a non-image file is rendered.
|
||||
// BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file
|
||||
// is rendered. #111 — image→image navigation now happens **in place**, so the
|
||||
// viewer (and these states) is no longer destroyed between siblings.
|
||||
const _imageViewerState = { lightbox: false, meta: false };
|
||||
// Set right before an image→image navigation (←/→ / filmstrip click) so
|
||||
// renderFile knows the state above must be carried over instead of reset.
|
||||
let _imageViewerNavPending = false;
|
||||
|
||||
/** Clamp a zoom factor into the supported [0.1, 8] range. */
|
||||
export function clampImageZoom(value) {
|
||||
@@ -578,6 +575,40 @@ function buildThumbUrl(vault, path, size) {
|
||||
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
|
||||
}
|
||||
|
||||
// #111 — directory listing cache. Opening / switching to another image must not
|
||||
// hammer `/api/browse` (the filmstrip and the sibling list share one listing).
|
||||
const _imageDirCache = new Map();
|
||||
const IMAGE_DIR_CACHE_TTL = 15000;
|
||||
|
||||
/** List the viewable image siblings of *dir*, memoised for a short TTL. */
|
||||
async function listImageSiblings(vault, dir) {
|
||||
const key = `${vault}\u0000${dir}`;
|
||||
const cached = _imageDirCache.get(key);
|
||||
if (cached && Date.now() - cached.ts < IMAGE_DIR_CACHE_TTL) return cached.items;
|
||||
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
|
||||
const items = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
|
||||
_imageDirCache.set(key, { items, ts: Date.now() });
|
||||
return items;
|
||||
}
|
||||
|
||||
const IMAGE_MIME = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
/** Best-effort MIME type for an image path (metadata panel). */
|
||||
function imageMimeFor(path) {
|
||||
const lower = (path || "").toLowerCase();
|
||||
const dot = lower.lastIndexOf(".");
|
||||
return dot === -1 ? "" : IMAGE_MIME[lower.slice(dot)] || "";
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!n && n !== 0) return "";
|
||||
if (n < 1024) return `${n} o`;
|
||||
@@ -592,20 +623,30 @@ function formatBytes(n) {
|
||||
*/
|
||||
export function renderImageViewer(area, data) {
|
||||
const vault = data.vault;
|
||||
const path = data.path;
|
||||
const fileName = (path || "").split("/").pop();
|
||||
const imgUrl = buildImageUrl(vault, path);
|
||||
// #111 — mutable current-image state: navigating swaps these in place instead
|
||||
// of re-rendering the whole viewer (which used to refetch the file + listing).
|
||||
let currentPath = data.path;
|
||||
let currentTitle = data.title || (currentPath || "").split("/").pop();
|
||||
let imgUrl = buildImageUrl(vault, currentPath);
|
||||
let currentMeta = {
|
||||
image_mime: data.image_mime || imageMimeFor(currentPath),
|
||||
size_bytes: data.size_bytes,
|
||||
modified: data.modified,
|
||||
};
|
||||
|
||||
area.innerHTML = "";
|
||||
const container = el("div", { class: "image-viewer-container", tabindex: "0" });
|
||||
|
||||
// ── Toolbar ────────────────────────────────────────────────────────────
|
||||
const toolbar = el("div", { class: "image-toolbar" });
|
||||
toolbar.appendChild(el("span", { class: "image-title", title: path }, [
|
||||
document.createTextNode(data.title || fileName),
|
||||
]));
|
||||
const titleEl = el("span", { class: "image-title", title: currentPath }, [
|
||||
document.createTextNode(currentTitle),
|
||||
]);
|
||||
toolbar.appendChild(titleEl);
|
||||
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
|
||||
|
||||
const counter = el("span", { class: "image-counter", hidden: true }, [document.createTextNode("")]);
|
||||
toolbar.appendChild(counter);
|
||||
const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]);
|
||||
toolbar.appendChild(zoomBadge);
|
||||
|
||||
@@ -630,8 +671,26 @@ export function renderImageViewer(area, data) {
|
||||
// ── Body: image stage + metadata sidebar (right) ───────────────────────
|
||||
const body = el("div", { class: "image-viewer-body" });
|
||||
const stage = el("div", { class: "image-stage" });
|
||||
const img = el("img", { class: "image-main", src: imgUrl, alt: data.title || fileName, draggable: "false" });
|
||||
const img = el("img", { class: "image-main", src: imgUrl, alt: currentTitle, draggable: "false" });
|
||||
stage.appendChild(img);
|
||||
|
||||
// ── Overlay arrows along the frame edges (#111) ────────────────────────
|
||||
const prevArrow = el("button", {
|
||||
class: "image-nav-arrow image-nav-arrow-prev",
|
||||
type: "button",
|
||||
title: t("viewer.image_prev"),
|
||||
"aria-label": t("viewer.image_prev"),
|
||||
hidden: true,
|
||||
}, [icon("chevron-left", 32)]);
|
||||
const nextArrow = el("button", {
|
||||
class: "image-nav-arrow image-nav-arrow-next",
|
||||
type: "button",
|
||||
title: t("viewer.image_next"),
|
||||
"aria-label": t("viewer.image_next"),
|
||||
hidden: true,
|
||||
}, [icon("chevron-right", 32)]);
|
||||
stage.appendChild(prevArrow);
|
||||
stage.appendChild(nextArrow);
|
||||
body.appendChild(stage);
|
||||
|
||||
// ── Metadata sidebar (kept open across ←/→ navigation) ─────────────────
|
||||
@@ -661,12 +720,12 @@ export function renderImageViewer(area, data) {
|
||||
|
||||
const renderMeta = () => {
|
||||
const rows = [
|
||||
[t("viewer.image_type"), data.image_mime || ""],
|
||||
[t("viewer.image_type"), currentMeta.image_mime || ""],
|
||||
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
|
||||
[t("viewer.metadata_size"), formatBytes(data.size_bytes)],
|
||||
[t("viewer.metadata_path"), path],
|
||||
[t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)],
|
||||
[t("viewer.metadata_path"), currentPath],
|
||||
];
|
||||
if (data.modified) rows.push([t("viewer.metadata_modified"), data.modified]);
|
||||
if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.modified]);
|
||||
metaPanel.innerHTML = "";
|
||||
const dl = el("dl", {});
|
||||
rows.forEach(([k, v]) => {
|
||||
@@ -676,46 +735,106 @@ export function renderImageViewer(area, data) {
|
||||
metaPanel.appendChild(dl);
|
||||
};
|
||||
|
||||
// ── Sibling navigation ─────────────────────────────────────────────────
|
||||
// ── Sibling navigation (in place, #111) ────────────────────────────────
|
||||
let siblings = [];
|
||||
let currentIndex = -1;
|
||||
let thumbs = [];
|
||||
const preloaded = new Set();
|
||||
|
||||
const preload = (i) => {
|
||||
if (!siblings.length) return;
|
||||
const it = siblings[(i % siblings.length + siblings.length) % siblings.length];
|
||||
if (!it || preloaded.has(it.path)) return;
|
||||
preloaded.add(it.path);
|
||||
const pre = new Image();
|
||||
pre.src = buildImageUrl(vault, it.path);
|
||||
};
|
||||
|
||||
const updateStripActive = () => {
|
||||
thumbs.forEach((thumb, i) => thumb.classList.toggle("active", i === currentIndex));
|
||||
const active = thumbs[currentIndex];
|
||||
if (!active) return;
|
||||
// Scroll the filmstrip only (never the document) to reveal the active thumb.
|
||||
const left = active.offsetLeft;
|
||||
const right = left + active.offsetWidth;
|
||||
if (left < strip.scrollLeft) strip.scrollLeft = Math.max(0, left - 8);
|
||||
else if (right > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = right - strip.clientWidth + 8;
|
||||
};
|
||||
|
||||
const updateArrows = () => {
|
||||
const multi = siblings.length > 1;
|
||||
prevArrow.hidden = nextArrow.hidden = prevBtn.disabled = nextBtn.disabled = !multi;
|
||||
prevArrow.disabled = nextArrow.disabled = !multi;
|
||||
counter.hidden = !multi;
|
||||
counter.textContent = multi ? `${currentIndex + 1} / ${siblings.length}` : "";
|
||||
};
|
||||
|
||||
/** Swap to the sibling at *index* without rebuilding the viewer. */
|
||||
const showSibling = (index) => {
|
||||
const item = siblings[index];
|
||||
if (!item || item.path === currentPath) return;
|
||||
currentIndex = index;
|
||||
currentPath = item.path;
|
||||
currentTitle = item.name;
|
||||
imgUrl = buildImageUrl(vault, currentPath);
|
||||
currentMeta = {
|
||||
image_mime: imageMimeFor(currentPath),
|
||||
size_bytes: item.size,
|
||||
modified: null,
|
||||
};
|
||||
scale = 1; tx = 0; ty = 0;
|
||||
applyTransform();
|
||||
img.style.display = "";
|
||||
const errEl = stage.querySelector(".image-error");
|
||||
if (errEl) errEl.remove();
|
||||
img.alt = currentTitle;
|
||||
img.src = imgUrl;
|
||||
titleEl.textContent = currentTitle;
|
||||
titleEl.title = currentPath;
|
||||
updateStripActive();
|
||||
updateArrows();
|
||||
state.currentVault = vault;
|
||||
state.currentPath = currentPath;
|
||||
try { syncActiveFileTreeItem(vault, currentPath); } catch (_) { /* best-effort */ }
|
||||
preload(index - 1);
|
||||
preload(index + 1);
|
||||
if (!metaPanel.hidden) renderMeta();
|
||||
};
|
||||
|
||||
const go = (delta) => {
|
||||
if (siblings.length < 2 || currentIndex < 0) return;
|
||||
const next = (currentIndex + delta + siblings.length) % siblings.length;
|
||||
_imageViewerNavPending = true;
|
||||
openFile(vault, siblings[next].path);
|
||||
showSibling((currentIndex + delta + siblings.length) % siblings.length);
|
||||
};
|
||||
|
||||
const renderStrip = () => {
|
||||
if (siblings.length < 2) { strip.hidden = true; return; }
|
||||
strip.hidden = false;
|
||||
strip.innerHTML = "";
|
||||
siblings.forEach((s, i) => {
|
||||
thumbs = siblings.map((s, i) => {
|
||||
const thumb = el("img", {
|
||||
class: `image-thumb${i === currentIndex ? " active" : ""}`,
|
||||
src: buildThumbUrl(vault, s.path, 96),
|
||||
alt: s.name,
|
||||
title: s.name,
|
||||
loading: "lazy",
|
||||
decoding: "async",
|
||||
});
|
||||
thumb.addEventListener("click", () => {
|
||||
if (s.path === path) return;
|
||||
_imageViewerNavPending = true;
|
||||
openFile(vault, s.path);
|
||||
});
|
||||
thumb.addEventListener("click", () => showSibling(i));
|
||||
strip.appendChild(thumb);
|
||||
return thumb;
|
||||
});
|
||||
updateStripActive();
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
||||
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
|
||||
siblings = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
|
||||
currentIndex = siblings.findIndex((it) => it.path === path);
|
||||
prevBtn.disabled = nextBtn.disabled = siblings.length < 2;
|
||||
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||||
siblings = await listImageSiblings(vault, dir);
|
||||
currentIndex = siblings.findIndex((it) => it.path === currentPath);
|
||||
renderStrip();
|
||||
updateArrows();
|
||||
preload(currentIndex - 1);
|
||||
preload(currentIndex + 1);
|
||||
} catch (_) { /* navigation is best-effort */ }
|
||||
})();
|
||||
|
||||
@@ -726,13 +845,15 @@ export function renderImageViewer(area, data) {
|
||||
setZoom(scale * factor);
|
||||
}, { passive: false });
|
||||
|
||||
const isArrow = (e) => !!(e.target && e.target.closest && e.target.closest(".image-nav-arrow"));
|
||||
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startTx = 0;
|
||||
let startTy = 0;
|
||||
stage.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.button !== 0 || isArrow(e)) return;
|
||||
dragging = true;
|
||||
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
|
||||
stage.classList.add("panning");
|
||||
@@ -753,7 +874,15 @@ export function renderImageViewer(area, data) {
|
||||
};
|
||||
stage.addEventListener("pointerup", endDrag);
|
||||
stage.addEventListener("pointercancel", endDrag);
|
||||
stage.addEventListener("dblclick", resetView);
|
||||
stage.addEventListener("dblclick", (e) => { if (!isArrow(e)) resetView(); });
|
||||
|
||||
const stopArrowPointer = (e) => e.stopPropagation();
|
||||
prevArrow.addEventListener("pointerdown", stopArrowPointer);
|
||||
nextArrow.addEventListener("pointerdown", stopArrowPointer);
|
||||
prevArrow.addEventListener("dblclick", stopArrowPointer);
|
||||
nextArrow.addEventListener("dblclick", stopArrowPointer);
|
||||
prevArrow.addEventListener("click", (e) => { e.stopPropagation(); go(-1); });
|
||||
nextArrow.addEventListener("click", (e) => { e.stopPropagation(); go(1); });
|
||||
|
||||
zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25));
|
||||
zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25));
|
||||
@@ -762,7 +891,7 @@ export function renderImageViewer(area, data) {
|
||||
nextBtn.addEventListener("click", () => go(1));
|
||||
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
|
||||
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
|
||||
window.open(dlUrl, "_blank");
|
||||
});
|
||||
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
|
||||
@@ -782,10 +911,17 @@ export function renderImageViewer(area, data) {
|
||||
syncLightbox();
|
||||
});
|
||||
|
||||
img.addEventListener("load", () => { if (!metaPanel.hidden) renderMeta(); });
|
||||
img.addEventListener("load", () => {
|
||||
img.style.display = "";
|
||||
const errEl = stage.querySelector(".image-error");
|
||||
if (errEl) errEl.remove();
|
||||
if (!metaPanel.hidden) renderMeta();
|
||||
});
|
||||
img.addEventListener("error", () => {
|
||||
stage.innerHTML = "";
|
||||
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
|
||||
img.style.display = "none";
|
||||
if (!stage.querySelector(".image-error")) {
|
||||
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
|
||||
}
|
||||
});
|
||||
|
||||
const onKey = (e) => {
|
||||
@@ -836,13 +972,13 @@ export function renderFile(data) {
|
||||
// #108 — release the image viewer's document-level shortcuts before swapping
|
||||
// the content area (otherwise they leak on every re-render).
|
||||
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
|
||||
// BUG-072 — carry the lightbox / metadata-panel state over an image→image
|
||||
// navigation; any other render starts from a clean viewer.
|
||||
if (!data.is_image || !_imageViewerNavPending) {
|
||||
// BUG-072 / #111 — any render that is not an image viewer starts from a clean
|
||||
// viewer. Image→image navigation now happens in place (see renderImageViewer),
|
||||
// so the viewer is never rebuilt between siblings.
|
||||
if (!data.is_image) {
|
||||
_imageViewerState.lightbox = false;
|
||||
_imageViewerState.meta = false;
|
||||
}
|
||||
_imageViewerNavPending = false;
|
||||
const area = getContentArea();
|
||||
// #110 — if this render is about to replace the surface that currently hosts
|
||||
// the shared media element, hand it back to the persistent dock first.
|
||||
|
||||
Reference in New Issue
Block a user