feat: support complet des images — arborescence, visionneuse, indexation #108
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Failing after 1m16s
CI / e2e (push) Skipped

This commit is contained in:
2026-09-23 07:47:04 -04:00
parent 69cee4d93a
commit eccbf7474e
31 changed files with 1317 additions and 138 deletions
+11 -11
View File
@@ -201,17 +201,17 @@ const EXT_ICONS = {
".tex": "file-text",
".latex": "file-text",
// Image files
".png": "file-image",
".jpg": "file-image",
".jpeg": "file-image",
".gif": "file-image",
".svg": "file-image",
".webp": "file-image",
".bmp": "file-image",
".ico": "file-image",
".tiff": "file-image",
".tif": "file-image",
// Image files (roadmap #108-D1)
".png": "image",
".jpg": "image",
".jpeg": "image",
".gif": "image",
".svg": "image",
".webp": "image",
".bmp": "image",
".ico": "image",
".tiff": "image",
".tif": "image",
// Audio files
".mp3": "file-music",
+247 -18
View File
@@ -540,12 +540,257 @@ export function navigatePdfToPage(area, page) {
iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`;
}
// ---------------------------------------------------------------------------
// Image viewer (roadmap #108-D)
// ---------------------------------------------------------------------------
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]);
const IMAGE_ZOOM_MIN = 0.1;
const IMAGE_ZOOM_MAX = 8;
let _imageViewerCleanup = null;
/** Clamp a zoom factor into the supported [0.1, 8] range. */
export function clampImageZoom(value) {
if (!Number.isFinite(value)) return 1;
return Math.min(IMAGE_ZOOM_MAX, Math.max(IMAGE_ZOOM_MIN, value));
}
/** True when *p* has a viewable image extension. */
export function isImagePath(p) {
const lower = (p || "").toLowerCase();
const dot = lower.lastIndexOf(".");
return dot !== -1 && IMAGE_EXTS.has(lower.slice(dot));
}
/** Build the byte-serving URL used by <img> / thumbnails. */
export function buildImageUrl(vault, path) {
return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function buildThumbUrl(vault, path, size) {
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
}
function formatBytes(n) {
if (!n && n !== 0) return "";
if (n < 1024) return `${n} o`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
return `${(n / 1048576).toFixed(1)} Mo`;
}
/**
* Render a full image viewer: centered image, wheel zoom (0.1×–8×), drag pan,
* double-click reset, ←/→ navigation between siblings, thumbnail filmstrip,
* collapsible metadata panel and a full-viewport lightbox.
*/
export function renderImageViewer(area, data) {
const vault = data.vault;
const path = data.path;
const fileName = (path || "").split("/").pop();
const imgUrl = buildImageUrl(vault, path);
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),
]));
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]);
toolbar.appendChild(zoomBadge);
const mkBtn = (iconName, label, cls) => {
const b = el("button", { class: `btn-action${cls ? " " + cls : ""}`, type: "button", title: label, "aria-label": label }, [icon(iconName, 14)]);
return b;
};
const zoomOutBtn = mkBtn("zoom-out", t("viewer.image_zoom_out"));
const zoomInBtn = mkBtn("zoom-in", t("viewer.image_zoom_in"));
const zoomResetBtn = mkBtn("rotate-ccw", t("viewer.image_zoom_reset"));
const prevBtn = mkBtn("chevron-left", t("viewer.image_prev"));
const nextBtn = mkBtn("chevron-right", t("viewer.image_next"));
const originalBtn = mkBtn("external-link", t("viewer.image_open_original"));
const downloadBtn = mkBtn("download", t("viewer.download"));
const metaBtn = mkBtn("info", t("viewer.image_metadata"));
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"));
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
.forEach((b) => toolbar.appendChild(b));
container.appendChild(toolbar);
// ── Stage (image + pan/zoom) ───────────────────────────────────────────
const stage = el("div", { class: "image-stage" });
const img = el("img", { class: "image-main", src: imgUrl, alt: data.title || fileName, draggable: "false" });
stage.appendChild(img);
container.appendChild(stage);
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
const strip = el("div", { class: "image-nav-strip", hidden: true });
container.appendChild(strip);
// ── Metadata panel ─────────────────────────────────────────────────────
const metaPanel = el("div", { class: "image-meta-panel" });
metaPanel.hidden = true;
container.appendChild(metaPanel);
let scale = 1;
let tx = 0;
let ty = 0;
const applyTransform = () => {
img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
zoomBadge.textContent = `${Math.round(scale * 100)}%`;
};
const resetView = () => { scale = 1; tx = 0; ty = 0; applyTransform(); };
const setZoom = (next) => {
scale = clampImageZoom(next);
if (scale === 1) { tx = 0; ty = 0; }
applyTransform();
};
const renderMeta = () => {
const rows = [
[t("viewer.image_type"), data.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],
];
if (data.modified) rows.push([t("viewer.metadata_modified"), data.modified]);
metaPanel.innerHTML = "";
const dl = el("dl", {});
rows.forEach(([k, v]) => {
dl.appendChild(el("dt", {}, [document.createTextNode(k)]));
dl.appendChild(el("dd", {}, [document.createTextNode(v || "—")]));
});
metaPanel.appendChild(dl);
};
// ── Sibling navigation ─────────────────────────────────────────────────
let siblings = [];
let currentIndex = -1;
const go = (delta) => {
if (siblings.length < 2 || currentIndex < 0) return;
const next = (currentIndex + delta + siblings.length) % siblings.length;
openFile(vault, siblings[next].path);
};
const renderStrip = () => {
if (siblings.length < 2) { strip.hidden = true; return; }
strip.hidden = false;
strip.innerHTML = "";
siblings.forEach((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",
});
thumb.addEventListener("click", () => { if (s.path !== path) openFile(vault, s.path); });
strip.appendChild(thumb);
});
};
(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;
renderStrip();
} catch (_) { /* navigation is best-effort */ }
})();
// ── Interactions ───────────────────────────────────────────────────────
stage.addEventListener("wheel", (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
setZoom(scale * factor);
}, { passive: false });
let dragging = false;
let startX = 0;
let startY = 0;
let startTx = 0;
let startTy = 0;
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragging = true;
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
stage.classList.add("panning");
if (stage.setPointerCapture) stage.setPointerCapture(e.pointerId);
});
stage.addEventListener("pointermove", (e) => {
if (!dragging) return;
tx = startTx + (e.clientX - startX);
ty = startTy + (e.clientY - startY);
applyTransform();
});
const endDrag = (e) => {
dragging = false;
stage.classList.remove("panning");
if (stage.releasePointerCapture && e.pointerId != null) {
try { stage.releasePointerCapture(e.pointerId); } catch (_) { /* already released */ }
}
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
stage.addEventListener("dblclick", resetView);
zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25));
zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25));
zoomResetBtn.addEventListener("click", resetView);
prevBtn.addEventListener("click", () => go(-1));
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)}`;
window.open(dlUrl, "_blank");
});
metaBtn.addEventListener("click", () => {
metaPanel.hidden = !metaPanel.hidden;
if (!metaPanel.hidden) renderMeta();
});
lightboxBtn.addEventListener("click", () => {
container.classList.toggle("lightbox");
lightboxBtn.setAttribute("aria-pressed", container.classList.contains("lightbox") ? "true" : "false");
});
img.addEventListener("load", () => { if (!metaPanel.hidden) renderMeta(); });
img.addEventListener("error", () => {
stage.innerHTML = "";
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
});
const onKey = (e) => {
if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); }
else if (e.key === "ArrowRight") { e.preventDefault(); go(1); }
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
else if (e.key === "0") { e.preventDefault(); resetView(); }
else if (e.key === "Escape") { container.classList.remove("lightbox"); }
};
document.addEventListener("keydown", onKey);
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
area.appendChild(container);
safeCreateIcons();
applyTransform();
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
// instead of being wiped mid-session by a tab switch / sidebar click.
if (isInlineEditorActive()) detachInlineEditor();
// #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; }
const area = getContentArea();
// Handle PDF files — render in iframe with TOC sidebar
@@ -596,25 +841,9 @@ export function renderFile(data) {
return;
}
// Handle images
// Handle images — dedicated zoom/pan viewer (roadmap #108-D)
if (data.is_image) {
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
area.innerHTML = `
<div class="image-viewer-container">
<div class="file-toolbar">
<span class="file-info">${escapeHtml(data.title)}</span>
<button class="btn-action" onclick="window.open('${imgUrl}', '_blank')">
<i data-lucide="maximize" style="width:14px;height:14px"></i> Plein écran
</button>
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>
<div class="image-viewer-body">
${data.html}
</div>
</div>`;
lucide.createIcons();
renderImageViewer(area, data);
return;
}