/* ObsiGate — Viewer module */ import { api, AuthManager } from './auth.js'; import { state } from './state.js'; import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js'; import { isInlineEditorActive, queryEditor } from './editor-inline.js'; import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js'; import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js'; import { AutocompleteDropdown, performAdvancedSearch } from './search.js'; import { initDashboardTabs } from './sync.js'; import { renderMermaidBlocks } from './mermaid-viewer.js'; import { renderExcalidraw } from './excalidraw-viewer.js'; import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js'; import { openShareDialog } from './config.js'; import { cacheViewedFile, getCachedFile } from './offline.js'; import { t } from './i18n.js'; import { onFileRender } from './plugins.js'; // ── Multi-format export ──────────────────────────────────────────────────── // Downloads a file export (HTML / MD bundle / ePub) via the authenticated // export endpoints, showing a loading toast while the request is in flight. async function downloadExport(vault, path, format, fallbackName) { showToast(t("viewer.export_start"), "info"); const url = `/api/export/${format}?vault=${encodeURIComponent(vault)}&path=${encodeURIComponent(path)}`; try { const headers = AuthManager.getAuthHeaders() || {}; const res = await fetch(url, { credentials: "include", headers }); if (!res.ok) { let detail = ""; try { detail = (await res.json()).detail || ""; } catch (_) { /* ignore */ } throw new Error(detail || "HTTP " + res.status); } const blob = await res.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = fallbackName; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(a.href), 1000); showToast(t("viewer.export_done"), "success"); } catch (err) { console.error("Export error:", err); showToast(t("viewer.export_error") + " " + err.message, "error"); } } // Close any open export dropdown when clicking elsewhere. document.addEventListener("click", function (e) { if (e.target && e.target.closest && e.target.closest(".export-dropdown")) return; document.querySelectorAll(".export-menu").forEach(function (m) { m.style.display = "none"; }); }); // Pane-aware content area helper — delegates to PaneManager when split function getContentArea() { // Override set by PaneTabManager._renderFileInPane for cross-module rendering if (window._activePaneContentArea) return window._activePaneContentArea; if (window.PaneManager && window.PaneManager.isSplit() && window.getActiveContentArea) { return window.getActiveContentArea(); } return document.getElementById('content-area'); } // --------------------------------------------------------------------------- // Outline/TOC Manager // --------------------------------------------------------------------------- const OutlineManager = { /** * Slugify text to create valid IDs */ slugify(text) { return ( text .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .trim() || "heading" ); }, /** * Parse headings from markdown content */ parseHeadings() { const contentArea = document.querySelector(".md-content"); if (!contentArea) return []; const headings = []; const h2s = contentArea.querySelectorAll("h2"); const h3s = contentArea.querySelectorAll("h3"); const allHeadings = [...h2s, ...h3s].sort((a, b) => { return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); const usedIds = new Map(); allHeadings.forEach((heading) => { const text = heading.textContent.trim(); if (!text) return; const level = parseInt(heading.tagName[1]); let id = this.slugify(text); // Handle duplicate IDs if (usedIds.has(id)) { const count = usedIds.get(id) + 1; usedIds.set(id, count); id = `${id}-${count}`; } else { usedIds.set(id, 1); } // Inject ID into heading if not present if (!heading.id) { heading.id = id; } else { id = heading.id; } headings.push({ id, level, text, element: heading, }); }); return headings; }, /** * Render outline list */ renderOutline(headings) { const outlineList = document.getElementById("outline-list"); const outlineEmpty = document.getElementById("outline-empty"); if (!outlineList) return; outlineList.innerHTML = ""; if (!headings || headings.length === 0) { outlineList.hidden = true; if (outlineEmpty) { outlineEmpty.hidden = false; safeCreateIcons(); } return; } outlineList.hidden = false; if (outlineEmpty) outlineEmpty.hidden = true; headings.forEach((heading) => { const item = el( "a", { class: `outline-item level-${heading.level}`, href: `#${heading.id}`, "data-heading-id": heading.id, role: "link", }, [document.createTextNode(heading.text)], ); item.addEventListener("click", (e) => { e.preventDefault(); this.scrollToHeading(heading.id); }); outlineList.appendChild(item); }); state.headingsCache = headings; }, /** * Scroll to heading with smooth behavior */ scrollToHeading(headingId) { const heading = document.getElementById(headingId); if (!heading) return; const contentArea = getContentArea(); if (!contentArea) return; // Calculate offset for fixed header (if any) const headerHeight = 80; const headingTop = heading.offsetTop; contentArea.scrollTo({ top: headingTop - headerHeight, behavior: "smooth", }); // Update active state immediately this.setActiveHeading(headingId); }, /** * Set active heading in outline */ setActiveHeading(headingId) { if (state.activeHeadingId === headingId) return; state.activeHeadingId = headingId; const items = document.querySelectorAll(".outline-item"); items.forEach((item) => { if (item.getAttribute("data-heading-id") === headingId) { item.classList.add("active"); item.setAttribute("aria-current", "location"); // Scroll outline item into view item.scrollIntoView({ block: "nearest", behavior: "smooth" }); } else { item.classList.remove("active"); item.removeAttribute("aria-current"); } }); }, /** * Initialize outline for current document */ init() { const headings = this.parseHeadings(); this.renderOutline(headings); ScrollSpyManager.init(headings); ReadingProgressManager.init(); }, /** * Cleanup */ destroy() { ScrollSpyManager.destroy(); ReadingProgressManager.destroy(); state.headingsCache = []; state.activeHeadingId = null; }, }; // --------------------------------------------------------------------------- // Scroll Spy Manager // --------------------------------------------------------------------------- const ScrollSpyManager = { observer: null, headings: [], init(headings) { this.destroy(); this.headings = headings; if (!headings || headings.length === 0) return; const contentArea = getContentArea(); if (!contentArea) return; const options = { root: contentArea, rootMargin: "-20% 0px -70% 0px", threshold: [0, 0.3, 0.5, 1.0], }; this.observer = new IntersectionObserver((entries) => { // Find the most visible heading let mostVisible = null; let maxRatio = 0; entries.forEach((entry) => { if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { maxRatio = entry.intersectionRatio; mostVisible = entry.target; } }); if (mostVisible && mostVisible.id) { OutlineManager.setActiveHeading(mostVisible.id); } }, options); // Observe all headings headings.forEach((heading) => { if (heading.element) { this.observer.observe(heading.element); } }); }, destroy() { if (this.observer) { this.observer.disconnect(); this.observer = null; } this.headings = []; }, }; // --------------------------------------------------------------------------- // Reading Progress Manager // --------------------------------------------------------------------------- const ReadingProgressManager = { scrollHandler: null, init() { this.destroy(); const contentArea = getContentArea(); if (!contentArea) return; this.scrollHandler = this.throttle(() => { this.updateProgress(); }, 100); contentArea.addEventListener("scroll", this.scrollHandler); this.updateProgress(); }, updateProgress() { const contentArea = getContentArea(); const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); if (!contentArea || !progressFill || !progressText) return; const scrollTop = contentArea.scrollTop; const scrollHeight = contentArea.scrollHeight; const clientHeight = contentArea.clientHeight; const maxScroll = scrollHeight - clientHeight; const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0; progressFill.style.width = `${percentage}%`; progressText.textContent = `${percentage}%`; }, throttle(func, delay) { let lastCall = 0; return function (...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; func.apply(this, args); } }; }, destroy() { const contentArea = getContentArea(); if (contentArea && this.scrollHandler) { contentArea.removeEventListener("scroll", this.scrollHandler); } this.scrollHandler = null; // Reset progress const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); if (progressFill) progressFill.style.width = "0%"; if (progressText) progressText.textContent = "0%"; }, }; // --------------------------------------------------------------------------- // File viewer // --------------------------------------------------------------------------- export async function openFile(vaultName, filePath) { state.currentVault = vaultName; state.currentPath = filePath; state.showingSource = false; state.cachedRawSource = null; // Highlight active syncActiveFileTreeItem(vaultName, filePath); // Show loading state while fetching const area = getContentArea(); area.innerHTML = '
Chargement...
'; try { const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; const data = await api(url); // Cache the file for offline use if (data && !data.unsupported && data.content) { cacheViewedFile(vaultName, filePath, data.content, data.modified); } renderFile(data); } catch (err) { // Try IndexedDB fallback console.log('[Viewer] Network failed, trying offline cache:', err); const cached = await getCachedFile(vaultName, filePath); if (cached && cached.content) { const fakeData = { vault: vaultName, path: filePath, title: filePath.split('/').pop().replace(/\.md$/, ''), content: cached.content, html: null, raw_size: cached.content.length, modified: cached.modified || '', tags: [], offline: true }; renderFile(fakeData); showToast(t('viewer.loaded_from_cache'), 'info'); } else { area.innerHTML = '

Impossible de charger le fichier — vous etes hors-ligne et ce fichier n\'est pas en cache.

'; } } } async function renderBacklinksPanel(vault, path, container) { try { const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`); if (!data.backlinks || data.backlinks.length === 0) return; const panel = el("div", { class: "backlinks-panel" }); const header = el("div", { class: "backlinks-header" }, [ icon("link", 14), document.createTextNode(` ${data.total} lien(s) entrant(s)`), ]); panel.appendChild(header); const list = el("div", { class: "backlinks-list" }); data.backlinks.forEach((bl) => { const item = el("div", { class: "backlink-item" }); const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]); const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]); item.appendChild(icon(getFileIcon(bl.path), 12)); item.appendChild(vaultBadge); item.appendChild(titleEl); item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path)); item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); }); list.appendChild(item); }); panel.appendChild(list); container.appendChild(panel); } catch (err) { // Silently ignore — backlinks are optional console.debug("Backlinks fetch failed:", err); } } // ── Pretty print (mode lecture enrichi pour fichiers texte non-markdown) ── // Detecte le langage depuis le HTML deja rendu par le backend (classe // language-xxx), formate le JSON, puis genere un bloc avec gouttiere de // numeros de ligne. La coloration est appliquee ensuite via highlight.js. function detectPrettyLang(data) { if (data.is_json || data.extension === ".json") return "json"; const m = /class="language-([\w-]+)/.exec(data.html || ""); return m ? m[1] : "plaintext"; } function buildPrettyHtml(data, raw) { let text = raw; const lang = detectPrettyLang(data); if (lang === "json") { try { text = JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { /* JSON invalide: on affiche brut */ } } const lines = text.split("\n"); const gutter = lines.map((_, i) => `${i + 1}`).join(""); return `
${escapeHtml(text)}
`; } // Colorateur générique de secours: utilisé quand highlight.js ne produit // aucun token (langage plaintext — .txt/.log —, langue absente du bundle, // ou CDN hljs indisponible). Réutilise les classes hljs existantes (le // thème CDN colore string/comment/number/attr) + classes .pp-* maison // pour les niveaux de log qui n'ont pas d'équivalent sémantique hljs. const PP_TOKEN_RE = /("[^"\n]*"?|'[^'\n]*'?|`[^`\n]*`?)|(^\s*(?:#|\/\/|;)\s.*$|\s+(?:#|\/\/)\s.*$)|\b(\d+(?:\.\d+)?)\b|\b(ERROR|FATAL|CRITICAL|SEVERE|WARN(?:ING)?|INFO|DEBUG|TRACE|NOTICE)\b|^(\s*[\w.\-\/ ]+)=(?=\S)|\b(true|false|null|None|TRUE|FALSE|NULL)\b/gm; function ppClassFor(m) { if (m[1] !== undefined) return "hljs-string"; if (m[2] !== undefined) return "hljs-comment"; if (m[3] !== undefined) return "hljs-number"; if (m[4] !== undefined) { const lvl = m[4]; if (/ERROR|FATAL|CRITICAL|SEVERE/.test(lvl)) return "pp-level pp-err"; if (/WARN/.test(lvl)) return "pp-level pp-warn"; if (/INFO|NOTICE/.test(lvl)) return "pp-level pp-info"; return "pp-level pp-debug"; } if (m[5] !== undefined) return "hljs-attr"; if (m[6] !== undefined) return "hljs-literal"; return null; } function fallbackTokenize(text) { let out = ""; let last = 0; PP_TOKEN_RE.lastIndex = 0; let m; while ((m = PP_TOKEN_RE.exec(text)) !== null) { if (m.index > last) out += escapeHtml(text.slice(last, m.index)); const cls = ppClassFor(m); out += cls ? `${escapeHtml(m[0])}` : escapeHtml(m[0]); last = m.index + (m[0].length || 1); if (m[0].length === 0) PP_TOKEN_RE.lastIndex++; // garde anti-boucle infinie } out += escapeHtml(text.slice(last)); return out; } // Applique la coloration: hljs (avec aliases de languages) si des tokens // sortent, sinon fallback regex generique. function applyPrettyHighlight(codeEl, lang, text) { safeHighlight(codeEl); // gerne les alias language-xxx inconnus de hljs const tokens = codeEl.querySelectorAll('[class^="hljs-"]').length; if (!tokens && text.trim()) { codeEl.innerHTML = fallbackTokenize(text); codeEl.classList.add("hljs"); } } /** * Jump the inline PDF viewer to a specific page. * * The browser's built-in PDF viewer lives in an ``about:blank`` content window * (so ``contentWindow.location.hash`` never reaches the document) **and** it * ignores a same-document fragment navigation: changing only ``#page=N`` on the * iframe ``src`` does not move the page. A changing query parameter forces a * real reload, and the ``#page=N`` fragment is then honoured at load — the only * reliable way to target a page with the native viewer. * * @param {HTMLElement} area - Content area containing the ``.pdf-iframe``. * @param {string|number} page - 1-based page number from the PDF outline. */ export function navigatePdfToPage(area, page) { const iframe = area && area.querySelector('.pdf-iframe'); if (!iframe || page === null || page === undefined || page === '') return; const base = iframe.getAttribute('data-pdf-url') || iframe.src.split('#')[0]; iframe.setAttribute('data-pdf-url', base); const sep = base.includes('?') ? '&' : '?'; 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 / 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 */ } } let _mediaViewerCleanup = null; /** Format a duration in seconds as ``m:ss`` (or ``h:mm:ss``). */ export function formatMediaDuration(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return ""; const total = Math.floor(seconds); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const pad = (n) => String(n).padStart(2, "0"); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } /** Build the byte-range streaming URL used by the HTML5 media players (#109). */ export function buildMediaUrl(vault, path) { return `/api/media/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`; } function buildDownloadUrl(vault, path) { return `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`; } /** * Fallback shown when a media codec is not playable in the browser (roadmap * #109-C3) or when the file is too large to stream inline. */ export function renderMediaFallback(area, data, message) { const fileName = (data.path || "").split("/").pop(); const sizeStr = formatBytes(data.size_bytes); area.innerHTML = ""; const box = el("div", { class: "unsupported-file" }, [ icon("file", 48), el("div", { class: "filename" }, [document.createTextNode(fileName)]), el("div", {}, [document.createTextNode(message)]), sizeStr ? el("div", { style: "font-size:0.85rem;margin-top:4px" }, [document.createTextNode(t("viewer.metadata_size") + " : " + sizeStr)]) : null, ]); const dlBtn = el("button", { class: "btn-action", type: "button" }, [ icon("download", 14), document.createTextNode(" " + t("viewer.download")), ]); dlBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank")); const openBtn = el("button", { class: "btn-action", type: "button" }, [ icon("external-link", 14), document.createTextNode(" " + t("viewer.image_open_original")), ]); openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank")); box.appendChild(el("div", { class: "media-fallback-actions" }, [dlBtn, openBtn])); area.appendChild(box); safeCreateIcons(); } function mediaToolbar(data, extraButtons) { const toolbar = el("div", { class: "media-toolbar" }); toolbar.appendChild(el("span", { class: "media-title", title: data.path }, [ document.createTextNode(data.title || (data.path || "").split("/").pop()), ])); toolbar.appendChild(el("div", { class: "media-toolbar-spacer" })); (extraButtons || []).forEach((b) => toolbar.appendChild(b)); const openBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.image_open_original"), "aria-label": t("viewer.image_open_original") }, [icon("external-link", 14)]); openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank")); const downloadBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.download"), "aria-label": t("viewer.download") }, [icon("download", 14)]); downloadBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank")); toolbar.appendChild(openBtn); toolbar.appendChild(downloadBtn); return toolbar; } /** * Render an audio player (roadmap #109-B): full-width native ``