feat: visionneuse d'images - navigation fluide, image ajustee au cadre, fleches laterales et pellicule persistante #111
CI / lint (push) Successful in 2m2s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 4m23s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 13m27s

This commit is contained in:
2026-09-23 14:37:41 -04:00
parent 8264e7ffae
commit b926f01b85
16 changed files with 460 additions and 70 deletions
+178 -42
View File
@@ -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.