/* 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, showConfirm, showPrompt } 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'; import { NowPlaying } from './now-playing.js'; import { openAssistant } from './ai-fab.js'; import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js'; import { buildCommandBar } from './xlsx/command-bar.js'; import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.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; // 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 }; /** 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}`; } // #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`; 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; // #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" }); 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); 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"), "image-btn-metadata"); const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"), "image-btn-lightbox"); [prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn] .forEach((b) => toolbar.appendChild(b)); container.appendChild(toolbar); // ── 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: 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) ───────────────── const metaPanel = el("div", { class: "image-meta-panel" }); metaPanel.hidden = !_imageViewerState.meta; body.appendChild(metaPanel); container.appendChild(body); // ── Thumbnail filmstrip (navigation) ─────────────────────────────────── // #112 — the film scrolls with the mouse wheel and two translucent edge // arrows; the scrollable strip is wrapped so the arrows stay fixed. const navWrap = el("div", { class: "image-nav", hidden: true }); const strip = el("div", { class: "image-nav-strip" }); const stripPrev = el("button", { class: "image-strip-arrow image-strip-arrow-prev", type: "button", title: t("viewer.image_strip_prev"), "aria-label": t("viewer.image_strip_prev"), }, [icon("chevron-left", 20)]); const stripNext = el("button", { class: "image-strip-arrow image-strip-arrow-next", type: "button", title: t("viewer.image_strip_next"), "aria-label": t("viewer.image_strip_next"), }, [icon("chevron-right", 20)]); navWrap.appendChild(strip); navWrap.appendChild(stripPrev); navWrap.appendChild(stripNext); container.appendChild(navWrap); 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"), currentMeta.image_mime || ""], [t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""], [t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)], [t("viewer.metadata_path"), currentPath], ]; if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.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 (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; showSibling((currentIndex + delta + siblings.length) % siblings.length); }; const renderStrip = () => { if (siblings.length < 2) { navWrap.hidden = true; return; } navWrap.hidden = false; strip.innerHTML = ""; 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", () => showSibling(i)); strip.appendChild(thumb); return thumb; }); updateStripActive(); }; // #112 — scroll the film with the wheel (vertical → horizontal) and the // two overlay arrows; the active image never changes, only the strip view. const scrollStrip = (dir) => { strip.scrollBy({ left: dir * Math.max(140, strip.clientWidth * 0.8), behavior: "smooth" }); }; stripPrev.addEventListener("click", () => scrollStrip(-1)); stripNext.addEventListener("click", () => scrollStrip(1)); strip.addEventListener("wheel", (e) => { if (strip.scrollWidth <= strip.clientWidth) return; const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; strip.scrollLeft += delta; e.preventDefault(); }, { passive: false }); (async () => { try { 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 */ } })(); // ── Interactions ─────────────────────────────────────────────────────── stage.addEventListener("wheel", (e) => { e.preventDefault(); const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; 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 || isArrow(e)) 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", (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)); 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(currentPath)}`; window.open(dlUrl, "_blank"); }); metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false"); metaBtn.addEventListener("click", () => { _imageViewerState.meta = !_imageViewerState.meta; metaPanel.hidden = !_imageViewerState.meta; metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false"); if (_imageViewerState.meta) renderMeta(); }); const syncLightbox = () => { container.classList.toggle("lightbox", _imageViewerState.lightbox); lightboxBtn.setAttribute("aria-pressed", _imageViewerState.lightbox ? "true" : "false"); }; syncLightbox(); lightboxBtn.addEventListener("click", () => { _imageViewerState.lightbox = !_imageViewerState.lightbox; syncLightbox(); }); img.addEventListener("load", () => { img.style.display = ""; const errEl = stage.querySelector(".image-error"); if (errEl) errEl.remove(); if (!metaPanel.hidden) renderMeta(); }); img.addEventListener("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) => { 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") { _imageViewerState.lightbox = false; syncLightbox(); } }; document.addEventListener("keydown", onKey); _imageViewerCleanup = () => document.removeEventListener("keydown", onKey); area.appendChild(container); safeCreateIcons(); applyTransform(); if (_imageViewerState.meta) renderMeta(); try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ } } // #110 — Audio/video are handled by the global Now Playing controller // (frontend/js/now-playing.js): a single shared media element is teleported // between this inline view and a body-level dock, so playback survives tab, // pane and page navigation. The inline branches below just hand over the // surface; the legacy "stop on re-render" cleanup no longer applies. export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js"; /** Audio viewer (roadmap #109-B, made persistent by #110). */ export function renderAudioViewer(area, data) { NowPlaying.attachInline(area, data); } /** Video viewer (roadmap #109-C, made persistent by #110). */ export function renderVideoViewer(area, data) { NowPlaying.attachInline(area, data); } // ── Excel .xlsx — sheet tabs + editable cells ───────────────────────────── // Cells are contenteditable; edits are collected per sheet and sent to // PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are // never recalculated here. // // #153 A1/A4 — the read response carries `xlsx_lossy_features` (parts openpyxl // drops on save): a banner lists them and the first save asks for an explicit // confirmation before retrying with `force: true`. A value starting with "=" or // "@" is stored as text unless the user turns the formula toggle on, so a typed // `=cmd|…` cannot execute when the file is later opened in Excel. // #153 A8 — a sheet bigger than the render caps used to be silently cut: the // user saw a short table and no way to tell the rest of the workbook still // existed. The backend now reports the real dimensions of every sheet, so the // note states exactly what is hidden (and that those cells are not editable // here — the workbook itself is untouched). A payload without those fields // (older cache) simply shows no note. function truncationNote(sheet) { // The cap is the real "shown" figure, not `rows`/`cols`: those are post-trim // (a sparse sheet renders 1x1) while the note must say how far the view // reaches. const cap = { rows: Number(sheet.max_rows) || 0, cols: Number(sheet.max_cols) || 0 }; const reasons = []; if (Number(sheet.total_rows) > cap.rows) { reasons.push(t("xlsx.truncated_rows", { shown: cap.rows, total: Number(sheet.total_rows) })); } if (Number(sheet.total_cols) > cap.cols) { reasons.push(t("xlsx.truncated_cols", { shown: cap.cols, total: Number(sheet.total_cols) })); } if (!reasons.length) return ""; return `
${escapeHtml(t("xlsx.truncated_title"))} ${escapeHtml(reasons.join(" "))} ${escapeHtml(t("xlsx.truncated_hint"))}
`; } export function renderXlsxViewer(area, data) { const sheets = data.xlsx_sheets || []; const lossy = data.xlsx_lossy_features || []; // #153 A16 — mode flags: a .csv rides the same viewer with a flat // structure (no tabs/merges/freeze to wire) and its own save endpoint; // .xls/.ods are served read-only (no editing, no structure ops). const isCsv = Boolean(data.is_csv); const readOnly = Boolean(data.xlsx_readonly); // Session-scoped state: once the lossy write is confirmed, the rest of the // session saves without asking again (never persisted — a confirmation is // per workbook, not a global preference). let lossyConfirmed = false; let allowFormula = false; // #154-A1 — the command bar is grouped (Formules · Insertion · Vue · Fichier) // and the sheet tabs are ALWAYS rendered (a single sheet used to show no tab // at all, hiding the fact that a workbook can hold several). An editable // workbook also gets an explicit “+” button next to the tabs. const editable = !isCsv && !readOnly; // #154-A5 — the shell markup (tabs + grouped command bar + status pills) is // built by frontend/js/xlsx/command-bar.js. const { tabs, actionsHtml, statusBar } = buildCommandBar({ sheets, isCsv, readOnly, editable }); // #153 A15 — inline styles (bold/italic/colors from the file itself), merged // ranges and the freeze anchor are applied AFTER the panels are in the DOM // (the HTML string alone cannot carry them: col/row spans need the table). const applySheetMeta = (panel) => { const meta = sheets[Number(panel.dataset.sheet)] || {}; const styleMap = meta.styles || {}; const alignMap = meta.aligns || {}; panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => { const ref = td.dataset.cell; const style = styleMap[ref]; if (style) td.setAttribute("style", style); const align = alignMap[ref]; if (align) td.style.textAlign = align; }); // Merged ranges: span the anchor cell over the range and hide the covered // cells (rowSpan/colSpan survive a re-render; hidden cells are skipped by // the save collector because their text was already blank in openpyxl). (meta.merges || []).forEach((range) => { const [from, to] = range.split(":"); const anchor = panel.querySelector(`td[data-cell="${from}"]`); const last = panel.querySelector(`td[data-cell="${to}"]`); if (!anchor || !last || anchor === last) return; const a = parseRef(from); const b = parseRef(to); if (!a || !b) return; anchor.rowSpan = Math.max(1, b.row - a.row + 1); anchor.colSpan = Math.max(1, b.col - a.col + 1); // Hide every covered cell of the block. for (let r = a.row; r <= b.row; r++) { for (let c = a.col; c <= b.col; c++) { if (r === a.row && c === a.col) continue; const covered = panel.querySelector(`td[data-cell="${columnName(c)}${r}"]`); if (covered) covered.style.display = "none"; } } }); // Freeze panes: sticky the rows/cols left of and above the anchor. const freeze = meta.freeze; if (freeze && freeze !== "A1") { const f = parseRef(freeze); if (f) { panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { const ref = tr.querySelector("td")?.dataset.cell || ""; const p = parseRef(ref); if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row"); }); panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => { const p = parseRef(td.dataset.cell); if (p && p.col < f.col) td.classList.add("xlsx-frozen-col"); }); } } }; // #154-A4 — ARIA grid semantics (the backend renders a plain ). const applyGridA11y = (panel) => { const table = panel.querySelector(".xlsx-table"); if (!table) return; table.setAttribute("role", "grid"); table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader")); table.querySelectorAll("tbody tr").forEach((tr) => { tr.setAttribute("role", "row"); tr.querySelectorAll("th, td").forEach((cell) => { cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell"); }); }); }; const panels = isCsv ? `
${sheets[0]?.html || data.html || ""}
` : sheets.map((s, i) => `` ).join(""); const lossWarning = lossy.length ? `
${escapeHtml(t("xlsx.lossy_title"))} ${lossy .map((f) => `${escapeHtml(t("xlsx.feature_" + f))}`) .join("")} ${escapeHtml(t("xlsx.lossy_hint"))}
` : ""; // #154-A1 — status pills and the grouped command bar are built by // buildCommandBar() (frontend/js/xlsx/command-bar.js). area.innerHTML = `
${lossWarning}
A1
${panels}
`; // ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ── // Fetched lazily on first open of the dashboard tab; a workbook without // anything notable hides the button entirely (no empty panel). if (!isCsv && !readOnly) { const dashBtn = el("button", { class: "btn-action xlsx-dash-btn", id: "xlsx-dashboard-btn", type: "button", title: t("xlsx.dashboard_btn"), }, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]); (area.querySelector("#xlsx-view-group") || area.querySelector(".xlsx-toolbar-actions")).appendChild(dashBtn); const inspector = area.querySelector("#xlsx-inspector"); const inspectorBody = area.querySelector("#xlsx-inspector-body"); const closeInspector = () => { const panel = inspectorBody.querySelector(".xlsx-dashboard"); if (panel) panel.remove(); inspector.classList.remove("open"); inspector.setAttribute("aria-hidden", "true"); dashBtn.classList.remove("active"); }; dashBtn.addEventListener("click", async () => { // #154-A3 — the dashboard lives in a right-hand inspector so the grid // stays visible beside it (it used to be prepended into the grid flow). if (inspectorBody.querySelector(".xlsx-dashboard")) { closeInspector(); return; } dashBtn.classList.add("active"); inspector.classList.add("open"); inspector.setAttribute("aria-hidden", "false"); const panel = document.createElement("div"); panel.className = "xlsx-dashboard"; panel.innerHTML = renderDashboardLoading(); inspectorBody.prepend(panel); try { const dash = await api( `/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`, ); panel.innerHTML = renderDashboardHtml(dash); safeCreateIcons(); // #154-A5 — clicking a named range reveals its first cell in the grid. panel.querySelectorAll(".xlsx-range-row[data-ref]").forEach((row) => { row.addEventListener("click", () => revealRange(row.dataset.ref)); }); } catch (err) { panel.innerHTML = `

${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}

`; } }); area.querySelector("#xlsx-inspector-close").addEventListener("click", closeInspector); // #154-A3 — the assistant is the existing global side panel; the inspector // just gives it a contextual entry point from the spreadsheet. area.querySelector("#xlsx-inspector-ai").addEventListener("click", () => { openAssistant(); }); // #154-A5 — the inspector is resizable (width restored per session). const resizeHandle = area.querySelector("#xlsx-inspector-resize"); const WIDTH_KEY = "obsigate_xlsx_inspector_width"; const clampWidth = (w) => Math.min(640, Math.max(260, w)); const savedWidth = Number(localStorage.getItem(WIDTH_KEY)); if (savedWidth >= 260 && savedWidth <= 640) { inspector.style.flexBasis = `${savedWidth}px`; inspector.style.width = `${savedWidth}px`; } let resizing = false; const onMove = (e) => { if (!resizing) return; const width = clampWidth(inspector.getBoundingClientRect().right - e.clientX); inspector.style.flexBasis = `${width}px`; inspector.style.width = `${width}px`; }; const onUp = () => { if (!resizing) return; resizing = false; document.body.style.userSelect = ""; localStorage.setItem(WIDTH_KEY, String(Math.round(inspector.getBoundingClientRect().width))); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; resizeHandle.addEventListener("mousedown", (e) => { e.preventDefault(); resizing = true; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }); } const saveBtn = area.querySelector("#xlsx-save-btn"); const panelEls = [...area.querySelectorAll(".xlsx-panel")]; const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length; const refreshSaveState = () => { const dirty = dirtyCount() > 0; saveBtn.disabled = !dirty; saveBtn.classList.toggle("is-dirty", dirty); // #154-A2 — star a sheet tab that holds unsaved edits. panelEls.forEach((panel) => { const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`); if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0); }); }; // #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept // and the user can retry the save without losing their work. const bannerHost = document.createElement("div"); bannerHost.className = "xlsx-banner-host"; area.querySelector(".xlsx-panels").before(bannerHost); const showConflict = (msg) => { bannerHost.innerHTML = ""; const banner = document.createElement("div"); banner.className = "xlsx-banner xlsx-banner-conflict"; banner.setAttribute("role", "alert"); banner.innerHTML = `${escapeHtml(msg)}`; banner.querySelector(".xlsx-banner-retry").addEventListener("click", () => { bannerHost.innerHTML = ""; saveBtn.click(); }); banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => { bannerHost.innerHTML = ""; }); bannerHost.appendChild(banner); safeCreateIcons(); }; // #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is // fetched window by window from GET …/xlsx/sheet when the user reaches the // end of a truncated sheet (scroll sentinel) or clicks « Charger la suite ». // Appended rows reuse the exact same edit pipeline as the initial render. const wireLazyRows = (panel) => { const meta = sheets[Number(panel.dataset.sheet)] || {}; if (!meta.truncated) return; const wrapper = panel.querySelector(".csv-table-wrapper"); const table = panel.querySelector(".xlsx-table tbody"); if (!wrapper || !table) return; let offset = Number(meta.rows) || 0; const total = Number(meta.total_rows) || 0; let loading = false; let done = offset >= total; const foot = document.createElement("div"); foot.className = "xlsx-load-more"; const refreshFoot = () => { foot.textContent = done ? "" : `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`; foot.classList.toggle("done", done); }; refreshFoot(); foot.addEventListener("click", () => { if (!done) loadMore(); }); wrapper.insertAdjacentElement("afterend", foot); const appendWindow = (win) => { const doc = new DOMParser().parseFromString(`
${win.html}
`, "text/html"); // No `tbody` selector: the fragment embeds its own wrapper div, so the // parse yields bare `` inside `` (the rows we want) — while // any `tbody` in the fragment belongs to the *embedded* wrapper table. doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr)); offset = win.offset + win.rows; done = !win.has_more; refreshFoot(); // The new rows must behave like the initial ones: contenteditable, // dirty tracking, cached-value tooltip, icons in the fresh footnote. panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell); panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1")); const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]"); cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); }); // #153 A15 — the window carries its own style/merge metadata: fold it // into the sheet meta and re-apply, so lazy rows look like the initial // render (applySheetMeta is idempotent over already-processed cells). const sheetMeta = sheets[Number(panel.dataset.sheet)]; if (sheetMeta) { Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {}); Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {}); if (!sheetMeta.merges) sheetMeta.merges = []; (win.merges || []).forEach((m) => { if (!sheetMeta.merges.includes(m)) sheetMeta.merges.push(m); }); if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze; applySheetMeta(panel); } applyGridA11y(panel); safeCreateIcons(); }; const loadMore = async () => { if (loading || done) return; loading = true; const label = foot.textContent; foot.textContent = t("xlsx.loading_more"); try { const win = await api( `/api/file/${encodeURIComponent(data.vault)}/xlsx/sheet?path=${encodeURIComponent(data.path)}` + `&sheet=${encodeURIComponent(sheets[Number(panel.dataset.sheet)].name)}` + `&offset=${offset}&limit=500`, ); appendWindow(win); } catch (err) { foot.textContent = label; // restore: a failed fetch must not eat the button showToast(`${t("xlsx.load_error")}: ${err.message || err}`, "error"); } finally { loading = false; } }; // #154-A4 — prefer IntersectionObserver (less main-thread work than a // scroll listener); the scroll listener stays as a fallback for // environments without IO (JSDOM). if (typeof IntersectionObserver === "function") { const io = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) loadMore(); }, { root: wrapper, rootMargin: "120px" }); io.observe(foot); } else { wrapper.addEventListener("scroll", () => { if (loading || done) return; if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore(); }); } }; // ── #153 A7 — keyboard navigation & formula bar ──────────────────────── // One active cell per viewer: clicking or arrowing into a cell shows its // A1 name in the bar; the input mirrors the cell text and typing there // edits the cell live (Enter commits, Escape reverts, then re-focuses). let activeTd = null; const activeCellEl = area.querySelector("#xlsx-active-cell"); const formulaInput = area.querySelector("#xlsx-formula-input"); const syncing = { value: false }; // guard against input-event feedback loops const cellName = (td) => td?.dataset.cell || ""; const setActiveCell = (td) => { activeTd = td || null; if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active"); if (!td) { activeCellEl.textContent = "—"; formulaInput.value = ""; formulaInput.disabled = true; return; } td.classList.add("xlsx-active"); activeCellEl.textContent = cellName(td); formulaInput.disabled = false; formulaInput.value = td.textContent; }; const syncFormulaBar = (td) => { if (td !== activeTd) return; if (!syncing.value) formulaInput.value = td.textContent; }; // #154-A5 — reveal a named range's first cell in the grid (dashboard → grid). const revealRange = (ref) => { const cellRef = firstCellOfRange(ref); if (!cellRef) return; const sheetName = sheetOfRef(ref); let panel = visiblePanel(); if (sheetName) { const idx = sheets.findIndex((s) => s.name === sheetName); const tab = idx >= 0 ? area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`) : null; if (tab) { tab.click(); panel = panelEls[idx]; } } const td = panel.querySelector(`td[data-cell="${cellRef}"]`); if (!td) { showToast(t("xlsx.range_not_visible"), "info"); return; } setActiveCell(td); td.focus(); if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "center", inline: "center" }); }; // #154-A5 — parseRef / columnName / findTd live in frontend/js/xlsx/refs.js. const moveActive = (td, key, forward = true) => { const ref = parseRef(cellName(td)); if (!ref) return; let { row, col } = ref; if (key === "Tab") col += forward ? 1 : -1; else if (key === "ArrowRight") col += 1; else if (key === "ArrowLeft") col -= 1; else if (key === "ArrowDown") row += 1; else if (key === "ArrowUp") row -= 1; if (row < 1 || col < 1) return; const panel = td.closest(".xlsx-panel"); const next = findTd(panel, row, col); if (!next) return; // edge of the rendered window: no wrap td.blur(); next.focus(); setActiveCell(next); }; formulaInput.addEventListener("input", () => { if (!activeTd) return; syncing.value = true; activeTd.textContent = formulaInput.value; syncing.value = false; activeTd.classList.add("xlsx-dirty"); refreshSaveState(); }); formulaInput.addEventListener("keydown", (e) => { if (!activeTd) return; if (e.key === "Enter") { e.preventDefault(); activeTd.blur(); activeTd.focus(); } else if (e.key === "Escape") { e.preventDefault(); activeTd.textContent = activeTd.dataset.orig; activeTd.classList.remove("xlsx-dirty"); syncFormulaBar(activeTd); refreshSaveState(); activeTd.focus(); } }); // ── #154-A4 — local undo/redo for cell edits ────────────────────────── // One command per committed edit (a cell loses focus with a new value). // Structure changes re-render from the server, which rebuilds the whole // viewer and therefore clears the stacks (they are per-render). const undoStack = []; const redoStack = []; let pendingEdit = null; const undoBtn = area.querySelector("#xlsx-undo-btn"); const redoBtn = area.querySelector("#xlsx-redo-btn"); const updateUndoButtons = () => { if (undoBtn) undoBtn.disabled = undoStack.length === 0; if (redoBtn) redoBtn.disabled = redoStack.length === 0; }; const commitEdit = () => { if (!pendingEdit) return; const { td, before } = pendingEdit; pendingEdit = null; const after = td.textContent; if (before === after) return; undoStack.push({ sheet: td.closest(".xlsx-panel")?.dataset.sheet, ref: td.dataset.cell, before, after }); redoStack.length = 0; updateUndoButtons(); }; const beginEdit = (td) => { if (pendingEdit && pendingEdit.td !== td) commitEdit(); if (!pendingEdit) pendingEdit = { td, before: td.textContent }; }; const applyHistory = (entry, value) => { const panel = area.querySelector(`.xlsx-panel[data-sheet="${entry.sheet}"]`) || panelEls[0]; const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`); if (!td) return; td.textContent = value; td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? "")); if (td === activeTd) syncFormulaBar(td); refreshSaveState(); }; const undo = () => { const entry = undoStack.pop(); if (!entry) return; applyHistory(entry, entry.before); redoStack.push(entry); updateUndoButtons(); }; const redo = () => { const entry = redoStack.pop(); if (!entry) return; applyHistory(entry, entry.after); undoStack.push(entry); updateUndoButtons(); }; if (undoBtn) undoBtn.addEventListener("click", undo); if (redoBtn) redoBtn.addEventListener("click", redo); area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => { if (!(e.ctrlKey || e.metaKey)) return; const key = e.key.toLowerCase(); if (key === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); } else if (key === "y") { e.preventDefault(); redo(); } }); const setupCell = (td) => { td.contentEditable = "true"; td.spellcheck = false; // Focusable without a pointing device: JSDOM requires it to fire focus // events on contenteditable cells, and a keyboard user tabbing into the // table from outside lands on the first cell thanks to it. td.tabIndex = 0; td.dataset.orig = td.textContent; td.addEventListener("input", () => { td.classList.add("xlsx-dirty"); syncFormulaBar(td); refreshSaveState(); }); td.addEventListener("focus", () => { beginEdit(td); setActiveCell(td); }); td.addEventListener("blur", commitEdit); td.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); } if (e.key === "Escape") { td.textContent = td.dataset.orig; td.classList.remove("xlsx-dirty"); syncFormulaBar(td); refreshSaveState(); } if (e.key === "Tab" || e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "ArrowLeft" || e.key === "ArrowRight") { // Excel-like: arrows and Tab move to the neighbour cell. Direction is // the key's own (Left/Up = back, Right/Down = forward); Tab follows // Shift. Prevented so Tab never leaves the table and arrows never // move the caret (they move the SELECTION instead). e.preventDefault(); const forward = e.key === "Tab" ? !e.shiftKey : (e.key === "ArrowRight" || e.key === "ArrowDown"); moveActive(td, e.key, forward); } }); td.addEventListener("paste", (e) => { e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " "); document.execCommand("insertText", false, text); }); }; // #153 A12 — the backend marks the last value Excel computed; the wording is // translated here so the tooltip follows the UI language. area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => { el.title = t("xlsx.cached_value_title"); }); // Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate. // Read-only formats (.xls/.ods) skip the wiring entirely — the toolbar // save button is disabled and the structure menu does not exist. if (readOnly) { saveBtn.disabled = true; } else { area.querySelectorAll(".xlsx-table td").forEach(setupCell); } panelEls.forEach(wireLazyRows); panelEls.forEach(applySheetMeta); panelEls.forEach(applyGridA11y); // The formula bar starts disabled: nothing is selected yet. setActiveCell(null); area.querySelectorAll(".xlsx-tab").forEach((tab) => { tab.addEventListener("click", () => { const idx = tab.dataset.sheet; area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x === tab)); panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; }); }); }); // Formula toggle (#153 A4) — opt-in for this viewing session only. // #154-A1 — absent on a read-only workbook (nothing can be saved). const formulaBtn = area.querySelector("#xlsx-formula-btn"); if (formulaBtn) formulaBtn.addEventListener("click", () => { allowFormula = !allowFormula; formulaBtn.setAttribute("aria-pressed", String(allowFormula)); formulaBtn.classList.toggle("active", allowFormula); }); const putSheet = isCsv ? (job, force) => api( `/api/file/${encodeURIComponent(data.vault)}/csv/save?path=${encodeURIComponent(data.path)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cells: job.cells }), }, ) : (job, force) => api( `/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...job, allow_formula: allowFormula, force }), }, ); saveBtn.addEventListener("click", async () => { // One PUT per sheet (dirty cells can span tabs before a save). const jobs = panelEls .map((panel) => { const cells = {}; panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; }); return { sheet: sheets[Number(panel.dataset.sheet)].name, cells }; }) .filter((job) => Object.keys(job.cells).length); if (!jobs.length) return; saveBtn.disabled = true; try { for (const job of jobs) { // 409 xlsx_lossy_content → confirm once, then retry with force: true. // (Also covers a workbook that became lossy while it was open.) let force = lossyConfirmed; for (;;) { try { await putSheet(job, force); break; } catch (err) { if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) { const features = (err.details && err.details.features) || lossy; const labels = features.map((f) => t("xlsx.feature_" + f)).join(", "); const proceed = await showConfirm({ title: t("xlsx.lossy_title"), message: t("xlsx.lossy_confirm", { features: labels }), confirmLabel: t("xlsx.lossy_confirm_btn"), danger: true, }); if (!proceed) throw err; lossyConfirmed = true; force = true; continue; } throw err; } } } area.querySelectorAll("td.xlsx-dirty").forEach((td) => { td.classList.remove("xlsx-dirty"); td.dataset.orig = td.textContent; }); if (activeTd) syncFormulaBar(activeTd); refreshSaveState(); showToast(t("editor.saved"), "success"); } catch (err) { refreshSaveState(); // A refused confirmation is a decision, not a failure: neutral toast. if (err && err.code === "xlsx_lossy_content") { showToast(t("xlsx.lossy_cancelled"), "info"); } else if (err && err.code === "conflict") { // #154-A2 — non-blocking: the edits are kept, the user may retry. showConflict(t("xlsx.conflict_msg")); } else { showToast(`${t("editor.save_error")}: ${err.message || err}`, "error"); } } }); area.querySelector("#xlsx-download-btn").addEventListener("click", () => { window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank"); }); // ── #153 A13 — sort / filter / find in the sheet + CSV export ────────── // ALL of these act on the RENDERED grid only: the workbook is never // rewritten by a sort or a filter (the save pipeline stays the only write // path, and the note in xlsx.sort_applied says so). const visiblePanel = () => panelEls.find((p) => p.style.display !== "none") || panelEls[0]; // Sorting reorders rows by the text of one column. Dirty cells travel // with their row, so a sort never loses an unsaved edit. let sortState = null; // { col, dir } on the visible panel const applySort = (panel, col, dir) => { const tbody = panel.querySelector(".xlsx-table tbody"); if (!tbody) return; const rows = [...tbody.querySelectorAll("tr")]; const numeric = rows.every((tr) => { const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`); const v = td ? td.textContent.trim() : ""; return v === "" || !isNaN(Number(v)); }); rows.sort((a, b) => { const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`); const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`); const va = ta ? ta.textContent.trim() : ""; const vb = tb ? tb.textContent.trim() : ""; const cmp = numeric ? (parseFloat(va) || 0) - (parseFloat(vb) || 0) : va.localeCompare(vb, "fr"); return dir === "asc" ? cmp : -cmp; }); rows.forEach((tr) => tbody.appendChild(tr)); sortState = { col, dir }; sortResetBtn.style.display = ""; showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info"); }; // Header click cycles: asc → desc → back to the sheet order. const wireHeaderSort = (panel) => { const thead = panel.querySelector(".xlsx-table thead"); if (!thead || thead.dataset.sortWired) return; thead.dataset.sortWired = "1"; thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => { th.style.cursor = "pointer"; th.title = t("xlsx.sort_asc"); th.addEventListener("click", () => { // The corner th is column 0, so the child index IS the column number. const col = [...th.parentElement.children].indexOf(th); const current = sortState && sortState.col === col ? sortState.dir : null; const dir = current === "asc" ? "desc" : "asc"; applySort(panel, col, dir); th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc"); }); }); }; // Filter: rows whose cell text lacks the needle are hidden. const applyFilter = (panel, needle) => { const n = needle.trim().toLowerCase(); panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { const text = tr.textContent.toLowerCase(); tr.style.display = !n || text.includes(n) ? "" : "none"; }); if (n) sortResetBtn.style.display = ""; }; panelEls.forEach(wireHeaderSort); const sortResetBtn = area.querySelector("#xlsx-sort-reset"); sortResetBtn.addEventListener("click", () => { // Full reset: re-render the current file (server truth), clear find too. sortResetBtn.style.display = "none"; renderXlsxViewer(area, data); }); // Find-in-sheet: highlight matches, navigate with ↑/↓. let findMatches = []; let findIndex = -1; const findInput = area.querySelector("#xlsx-find-input"); const findCount = area.querySelector("#xlsx-find-count"); let findCase = false; const clearFind = () => { findMatches.forEach(({ td, mark }) => { mark.replaceWith(document.createTextNode(mark.textContent)); td.normalize(); }); findMatches = []; findIndex = -1; findCount.textContent = ""; }; const runFind = () => { clearFind(); const needle = findInput.value; if (!needle.trim()) return; const panel = visiblePanel(); const hay = findCase ? (s) => s : (s) => s.toLowerCase(); const n = hay(needle); panel.querySelectorAll(".xlsx-table td").forEach((td) => { const text = td.textContent; const pos = hay(text).indexOf(n); if (pos === -1 || !text) return; // Wrap the match in a by splitting the cell's first text node at // the hit (cells are text-first; the cached-value span stays untouched). const first = td.firstChild; if (first && first.nodeType === 3) { const mark = document.createElement("mark"); mark.className = "xlsx-find-hit"; mark.textContent = text.substr(pos, needle.length); const tail = first.splitText(pos); tail.splitText(needle.length); td.replaceChild(mark, tail); findMatches.push({ td, mark }); } }); findCount.textContent = findMatches.length ? t("xlsx.find_count", { index: 1, count: findMatches.length }) : t("xlsx.find_no_match"); if (findMatches.length) focusMatch(0); }; const focusMatch = (i) => { findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current")); findIndex = (i + findMatches.length) % findMatches.length; const { td, mark } = findMatches[findIndex]; mark.classList.add("xlsx-find-current"); // scrollIntoView is missing in JSDOM; guard it (real browsers have it). if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" }); findCount.textContent = t("xlsx.find_count", { index: findIndex + 1, count: findMatches.length }); }; // One input drives both the highlight (find) and the row filter (A13): // typing narrows the sheet to the matching rows AND highlights the hits. findInput.addEventListener("input", () => { panelEls.forEach((p) => applyFilter(p, findInput.value)); runFind(); }); findInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); } }); area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1)); area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1)); const caseBtn = area.querySelector("#xlsx-find-case"); caseBtn.addEventListener("click", () => { findCase = !findCase; caseBtn.setAttribute("aria-pressed", String(findCase)); caseBtn.classList.toggle("active", findCase); runFind(); }); // ── #153 A14 — workbook structure menu (sheets, rows, columns) ───────── // Every action is an explicit user gesture (prompt/confirm) and goes to // PUT …/xlsx/structure — one locked, atomic rewrite with a backup. const visibleSheetIndex = () => Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0; const putStructure = async (actions, force = false) => { await api( `/api/file/${encodeURIComponent(data.vault)}/xlsx/structure?path=${encodeURIComponent(data.path)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ actions, force }), }, ); showToast(t("xlsx.structure_saved"), "success"); // Re-render from the server so the viewer shows the new structure. const fresh = await api( `/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, ); renderXlsxViewer(area, fresh); }; const structureError = async (err, retryActions) => { if (err && err.code === "xlsx_lossy_content") { const features = (err.details && err.details.features) || lossy; const labels = features.map((f) => t("xlsx.feature_" + f)).join(", "); const proceed = await showConfirm({ title: t("xlsx.lossy_title"), message: t("xlsx.lossy_confirm", { features: labels }), confirmLabel: t("xlsx.lossy_confirm_btn"), danger: true, }); if (proceed) return putStructure(retryActions, true); // re-emitted with force showToast(t("xlsx.lossy_cancelled"), "info"); return null; } showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error"); return null; }; // #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une // feuille » item, right where the tabs are (Excel-like). const tabAddBtn = area.querySelector("#xlsx-tab-add"); if (tabAddBtn) tabAddBtn.addEventListener("click", async () => { const name = await showPrompt({ title: t("xlsx.sheet_add"), message: t("xlsx.structure_prompt_add"), }); if (!name) return; const actions = [{ op: "sheet_add", name }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); // Structure menu: built on demand, positioned under the button. // (.csv / read-only workbooks have no such button — the menu block above // is skipped for them.) const structureBtn = area.querySelector("#xlsx-structure-btn"); if (structureBtn) structureBtn.addEventListener("click", (e) => { const old = area.querySelector(".xlsx-structure-menu"); if (old) { old.remove(); return; } const idx = visibleSheetIndex(); const sheetName = sheets[idx]?.name || ""; const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null); const parsed = parseRef(activeRef) || { row: 1, col: 1 }; const menu = document.createElement("div"); menu.className = "xlsx-structure-menu"; const item = (label, fn) => { const b = document.createElement("button"); b.type = "button"; b.className = "btn-action xlsx-structure-item"; b.textContent = label; b.addEventListener("click", () => { menu.remove(); fn(); }); menu.appendChild(b); }; item(t("xlsx.sheet_add"), async () => { const name = await showPrompt({ title: t("xlsx.sheet_add"), message: t("xlsx.structure_prompt_add"), }); if (!name) return; const actions = [{ op: "sheet_add", name }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); item(t("xlsx.sheet_rename"), async () => { const to = await showPrompt({ title: t("xlsx.sheet_rename"), message: t("xlsx.structure_prompt_rename"), value: sheetName, }); if (!to || to === sheetName) return; const actions = [{ op: "sheet_rename", from: sheetName, to }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); item(t("xlsx.sheet_duplicate"), async () => { const as = await showPrompt({ title: t("xlsx.sheet_duplicate"), message: t("xlsx.structure_prompt_add"), value: `${sheetName} (copie)`, }); if (!as) return; const actions = [{ op: "sheet_duplicate", name: sheetName, as }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); item(t("xlsx.sheet_delete"), async () => { if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; } const okDelete = await showConfirm({ title: t("xlsx.sheet_delete"), message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }), confirmLabel: t("xlsx.sheet_delete"), danger: true, }); if (!okDelete) return; const actions = [{ op: "sheet_delete", name: sheetName }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); if (parsed) { menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" })); item(t("xlsx.row_insert"), async () => { const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); item(t("xlsx.row_delete"), async () => { const okRow = await showConfirm({ title: t("xlsx.row_delete"), message: t("xlsx.structure_confirm_row", { n: parsed.row }), confirmLabel: t("xlsx.row_delete"), danger: true, }); if (!okRow) return; const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); item(t("xlsx.col_insert"), async () => { const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); item(t("xlsx.col_delete"), async () => { const okCol = await showConfirm({ title: t("xlsx.col_delete"), message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }), confirmLabel: t("xlsx.col_delete"), danger: true, }); if (!okCol) return; const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); } e.target.closest(".xlsx-toolbar").appendChild(menu); }); // CSV export of the visible sheet (post-trim, pre-save data). area.querySelector("#xlsx-csv-btn").addEventListener("click", () => { const panel = visiblePanel(); const idx = Number(panel.dataset.sheet); const rows = []; panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { rows.push( [...tr.querySelectorAll("td")].map((td) => { const clone = td.cloneNode(true); clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove()); return clone.textContent; }), ); }); const csv = rows .map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";")) .join("\n"); const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${sheets[idx]?.name || "feuille"}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); }); safeCreateIcons(); } 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; } // 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; } 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. NowPlaying.handleRender(area, data); // Handle PDF files — render in iframe with TOC sidebar if (data.is_pdf) { const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`; const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?'; const toc = data.pdf_toc || []; let tocHtml = ''; if (toc.length > 0) { tocHtml = '

Table des matières

'; } area.innerHTML = `
PDF — ${pages} pages
${tocHtml}
`; area.querySelectorAll('.pdf-toolbar .btn-action').forEach((btn) => { btn.addEventListener('click', () => { window.open(btn.dataset.pdfUrl || btn.dataset.downloadUrl, '_blank'); }); }); area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); navigatePdfToPage(area, link.getAttribute('data-page')); }); }); lucide.createIcons(); return; } // Handle Excel .xlsx — editable table view (display / edit / download) if (data.is_xlsx) { renderXlsxViewer(area, data); return; } // Handle CSV — same spreadsheet viewer, flat mode (#153 A16): the backend // renders the grid with data-cell refs and PUT …/csv/save persists edits. if (data.is_csv) { renderXlsxViewer(area, data); return; } // Handle Excalidraw — render in iframe editor if (data.is_excalidraw) { renderExcalidraw(area, data, data.vault, data.path); return; } // Handle images — dedicated zoom/pan viewer (roadmap #108-D) if (data.is_image) { renderImageViewer(area, data); return; } // Handle audio / video — native HTML5 players (roadmap #109) if (data.is_audio) { renderAudioViewer(area, data); return; } if (data.is_video) { renderVideoViewer(area, data); return; } // Handle unsupported (binary) files if (data.unsupported) { const sizeStr = data.size_bytes ? data.size_bytes < 1024 ? `${data.size_bytes} o` : data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko` : `${(data.size_bytes / 1048576).toFixed(1)} Mo` : ""; area.innerHTML = `
${escapeHtml(data.path.split("/").pop())}
${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}
${sizeStr ? `
Taille : ${sizeStr}
` : ""}
`; lucide.createIcons(); document.getElementById("unsupported-download-btn").addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; window.open(dlUrl, "_blank"); }); return; } // Breadcrumb const parts = data.path.split("/"); const breadcrumbEls = []; breadcrumbEls.push( makeBreadcrumbSpan(data.vault, () => { focusPathInSidebar(data.vault, "", { alignToTop: "center" }); }), ); let accumulated = ""; parts.forEach((part, i) => { breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")])); accumulated += (accumulated ? "/" : "") + part; const p = accumulated; if (i < parts.length - 1) { breadcrumbEls.push( makeBreadcrumbSpan(part, () => { focusPathInSidebar(data.vault, p, { alignToTop: "center" }); }), ); } else { breadcrumbEls.push( makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => { focusPathInSidebar(data.vault, data.path, { alignToTop: "center" }); }), ); } }); const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls); // Tags const tagsDiv = el("div", { class: "file-tags" }); (data.tags || []).forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); t.addEventListener("click", () => searchByTag(tag)); tagsDiv.appendChild(t); } }); // Action buttons // Cache local au rendu courant: state.cachedRawSource global est fiable // uniquement quand openFile() initialise le fichier (il le réinitialise). // Or le contenu d'un onglet peut être rendu via renderFile() direct sans // passer par openFile() — un cache partagé transporterait le contenu du // fichier précédent (bug pretty print sur fichiers successifs). let localRaw = null; const getRaw = async () => { if (localRaw != null) return localRaw; if (state.currentVault === data.vault && state.currentPath === data.path && state.cachedRawSource) { localRaw = state.cachedRawSource; return localRaw; } const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; const rawData = await api(rawUrl); localRaw = rawData.raw; return localRaw; }; const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]); copyBtn.addEventListener("click", async () => { try { const rawSrc = await getRaw(); if (navigator.clipboard) { await navigator.clipboard.writeText(rawSrc); } else { // Fallback for non-secure contexts const ta = document.createElement("textarea"); ta.value = rawSrc; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } copyBtn.lastChild.textContent = t('viewer.copied'); setTimeout(() => (copyBtn.lastChild.textContent = t('viewer.copy')), 1500); } catch (err) { console.error("Copy error:", err); showToast(t("viewer.copy_error"), "error"); } }); const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]); // Download raw file button (extension shown; .md label for markdown) const mdBtn = el("button", { class: "btn-action", title: t("viewer.download_file") || t("viewer.download_md") }, [icon("file-text", 14), document.createTextNode(data.extension || ".md")]); mdBtn.addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; const a = document.createElement("a"); a.href = dlUrl; a.download = data.path.split("/").pop(); document.body.appendChild(a); a.click(); document.body.removeChild(a); }); // PDF download button const pdfBtn = el("button", { class: "btn-action", title: t("viewer.download_pdf") }, [icon("file", 14), document.createTextNode("PDF")]); pdfBtn.addEventListener("click", () => { const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`; window.open(pdfUrl, "_blank"); }); // Export dropdown button (HTML / MD bundle / ePub) const exportWrap = el("div", { class: "export-dropdown", style: "position:relative;display:inline-block" }); const exportBtn = el("button", { class: "btn-action", title: t("viewer.export_title") }, [icon("download", 14), document.createTextNode(t("viewer.export"))]); const exportMenu = el("div", { class: "export-menu", style: "display:none;position:absolute;right:0;top:100%;z-index:1000;min-width:200px;padding:4px;background:var(--bg,#fff);border:1px solid #ccc;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.18)" }); const exportOptions = [ { format: "html", icon: "file-code", label: t("viewer.export_html"), ext: ".html" }, { format: "md-bundle", icon: "archive", label: t("viewer.export_md_bundle"), ext: ".zip" }, { format: "epub", icon: "book-open", label: t("viewer.export_epub"), ext: ".epub" }, ]; exportOptions.forEach((opt) => { const item = el("button", { class: "export-menu-item", style: "display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:none;background:none;cursor:pointer;border-radius:4px;text-align:left;font-size:13px" }, [ icon(opt.icon, 14), document.createTextNode(opt.label), ]); item.addEventListener("mouseenter", () => { item.style.background = "rgba(128,128,128,.15)"; }); item.addEventListener("mouseleave", () => { item.style.background = "none"; }); item.addEventListener("click", () => { exportMenu.style.display = "none"; const baseName = data.path.split("/").pop().replace(/\.[^.]+$/, "") || "document"; downloadExport(data.vault, data.path, opt.format, baseName + opt.ext); }); exportMenu.appendChild(item); }); exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block"; }); exportWrap.appendChild(exportBtn); exportWrap.appendChild(exportMenu); const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]); editBtn.addEventListener("click", () => { openEditor(data.vault, data.path); }); const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]); forgeBtn.addEventListener("click", () => { var modal = document.getElementById("editor-modal"); var bodyEl = document.getElementById("editor-body"); // Set editor state so parent buttons work state.editorVault = data.vault; state.editorPath = data.path; // #93 — Forge replaces the document read view when this file is the one // currently displayed; otherwise it falls back to the overlay. var inline = activateInlineEditor(data.vault, data.path); if (!inline) { // A stale inline session (another document) must release the area first. detachInlineEditor(); state.editorVault = data.vault; state.editorPath = data.path; } // Update header: show "Forge" brand + filename (the header travels with the // container in inline mode — query it wherever it lives, #93) var brand = queryEditor(".editor-brand"); var titleInp = document.getElementById("editor-title-input"); var fileInp = document.getElementById("editor-filename-input"); if (brand) brand.textContent = t("viewer.forge_brand"); if (titleInp) { titleInp.value = data.title || ''; titleInp.placeholder = t('forge.title_placeholder'); } if (fileInp) fileInp.value = data.path.split('/').pop(); bodyEl.innerHTML = ""; bodyEl.style.overflow = "hidden"; var iframe = document.createElement("iframe"); iframe.id = "forge-iframe"; iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now(); // #101 — allow the Forge editor's own Fullscreen button to work. iframe.setAttribute("allow", "fullscreen"); iframe.setAttribute("allowfullscreen", ""); // Inline: the iframe fills the content area; overlay: the original 82vh. iframe.style.cssText = "width:100%;height:" + (inline ? "100%" : "82vh") + ";border:none;display:block;"; bodyEl.appendChild(iframe); modal.classList.add("active"); safeCreateIcons(); }); const openNewWindowBtn = el("button", { class: "btn-action", title: t("forge.popout") }, [icon("external-link", 14), document.createTextNode(t("forge.popout_label"))]); openNewWindowBtn.addEventListener("click", () => { const theme = document.documentElement.getAttribute('data-theme') || 'dark'; const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}?theme=${theme}`; window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no"); }); const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]); tocBtn.addEventListener("click", () => { RightSidebarManager.toggle(); }); // Share button — check if already shared const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]); // Check if already shared and color the button (async () => { try { const shares = await api("/api/shares"); if (shares.some(s => s.vault === data.vault && s.path === data.path)) { shareBtn.classList.add("shared"); shareBtn.title = t('viewer.shared_doc_title'); } } catch (e) { /* ignore */ } })(); shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path)); // Bookmark button — check if already bookmarked const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]); // Check bookmark status and color the button (async () => { try { const bms = await api("/api/bookmarks"); if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) { bookmarkBtn.classList.add("active"); bookmarkBtn.title = t("viewer.bookmark_remove"); } } catch (e) { /* ignore */ } })(); bookmarkBtn.addEventListener("click", async () => { try { const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) }); bookmarkBtn.classList.toggle("active", res.bookmarked); bookmarkBtn.title = res.bookmarked ? t("viewer.bookmark_remove") : t("viewer.bookmark_add"); showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); // Frontmatter — Accent Card let fmSection = null; if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { fmSection = buildFrontmatterCard(data.frontmatter); } // Content container (rendered HTML) const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" }); mdDiv.innerHTML = data.html; // Raw source container (hidden initially) const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" }); // Source button toggle logic sourceBtn.addEventListener("click", async () => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; state.showingSource = !state.showingSource; if (state.showingSource) { sourceBtn.classList.add("active"); raw.textContent = await getRaw(); rendered.style.display = "none"; raw.style.display = "block"; } else { sourceBtn.classList.remove("active"); rendered.style.display = "block"; raw.style.display = "none"; } }); // Pretty print — mode lecture enrichi (numeros de ligne + coloration) // pour les fichiers texte non-markdown (code, JSON, configs, logs...) let prettyBtn = null; const isTextViewerFile = !data.is_markdown && !data.is_csv && !data.unsupported && !data.is_pdf && !data.is_image && !data.is_excalidraw; if (isTextViewerFile) { prettyBtn = el("button", { class: "btn-action", title: t("viewer.pretty_title") || "Mode formaté avec couleurs et numéros de ligne" }, [icon("sparkles", 14), document.createTextNode(t("viewer.pretty") || "Pretty")]); prettyBtn.addEventListener("click", async () => { const on = !prettyBtn.classList.contains("active"); if (on) { let rawText = ""; try { rawText = await getRaw(); } catch (_) { rawText = mdDiv.textContent || ""; } mdDiv.innerHTML = buildPrettyHtml(data, rawText); const codeEl = mdDiv.querySelector("code"); applyPrettyHighlight(codeEl, detectPrettyLang(data), rawText); prettyBtn.classList.add("active"); } else { mdDiv.innerHTML = data.html; mdDiv.querySelectorAll("pre code").forEach((block) => safeHighlight(block)); prettyBtn.classList.remove("active"); } }); } // Separateur visuel entre les groupes d'actions (#90) const actionSep = () => el("span", { class: "action-sep" }); // Ordre des boutons par groupes fonctionnels (#90) : // markdown : [TOC][pop-out][Bookmark] | [Editer][Source][.md][Forge] | [Copier][PDF][Export] | [Partager] // texte autre : [pop-out][Bookmark] | [Editer][Source][.ext][Pretty] | [Copier] | [Partager] // Boutons non applicables au type de fichier (TOC hors markdown, Pretty hors // texte, PDF/Export hors markdown, ...) restent ancres a leur groupe ; // spacer de groupe vide retire. const navBtns = []; if (data.is_markdown) navBtns.push(tocBtn); navBtns.push(openNewWindowBtn, bookmarkBtn); const editBtns = []; if (!data.unsupported) editBtns.push(editBtn); editBtns.push(sourceBtn); if (!data.is_image) editBtns.push(mdBtn); if (data.is_markdown) editBtns.push(forgeBtn); if (prettyBtn) editBtns.push(prettyBtn); const exportBtns = [copyBtn]; if (data.is_markdown) exportBtns.push(pdfBtn, exportWrap); const shareBtns = [shareBtn]; const fileActions = []; [navBtns, editBtns, exportBtns, shareBtns].forEach((group) => { if (group.length === 0) return; if (fileActions.length > 0) fileActions.push(actionSep()); group.forEach((b) => fileActions.push(b)); }); // Assemble area.innerHTML = ""; area.appendChild(breadcrumb); area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv])); // #115 — the action bar is a direct child of the scroll container (wrapped in // `.file-toolbar`) so it can stay pinned while long documents scroll; nested // inside `.file-header` it would stop sticking as soon as the header left the // viewport. area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)])); if (fmSection) area.appendChild(fmSection); area.appendChild(mdDiv); area.appendChild(rawDiv); // Backlinks panel if (data.is_markdown) { renderBacklinksPanel(data.vault, data.path, area); } // Highlight code blocks area.querySelectorAll("pre code").forEach((block) => { safeHighlight(block); }); // Add copy buttons to code blocks area.querySelectorAll("pre").forEach((pre) => { if (pre.parentElement?.classList.contains("code-block-wrapper")) return; const wrapper = el("div", { class: "code-block-wrapper" }, []); wrapper.style.position = "relative"; pre.replaceWith(wrapper); wrapper.appendChild(pre); const btn = el("button", { class: "copy-code-btn", title: "Copier le code" }, [document.createTextNode("Copier")]); btn.addEventListener("click", (e) => { e.stopPropagation(); const code = pre.querySelector("code")?.textContent || pre.textContent; const copied = copyToClipboard(code); if (copied) { btn.textContent = "✓ Copié"; btn.classList.add("copied"); if (typeof showToast === "function") showToast("Code copié dans le presse-papiers", "success"); setTimeout(() => { btn.textContent = "Copier"; btn.classList.remove("copied"); }, 2000); } else { if (typeof showToast === "function") showToast("Erreur lors de la copie", "error"); } }); wrapper.appendChild(btn); }); // Render Mermaid diagrams renderMermaidBlocks(area); // Wire up wikilinks area.querySelectorAll(".wikilink").forEach((link) => { link.addEventListener("click", (e) => { e.preventDefault(); const v = link.getAttribute("data-vault"); const p = link.getAttribute("data-path"); if (v && p) TabManager.openPreview(v, p); }); }); safeCreateIcons(); area.scrollTop = 0; // Initialize outline/TOC for this document OutlineManager.init(); // Plugin hooks: onFileRender — allow plugins to transform the rendered // content or append custom widgets beneath it. Async + non-blocking so a // slow/failed plugin never delays the normal file render. if (data.content && data.is_markdown) { onFileRender(data.vault, data.content, data.path).then((fr) => { if (!fr) return; if (typeof fr.replace === 'string' && fr.replace !== data.content) { const rendered = document.getElementById('file-rendered-content'); if (rendered) rendered.innerHTML = fr.replace; } if (Array.isArray(fr.append) && fr.append.length > 0) { const out = el('div', { class: 'plugin-render-output' }); fr.append.forEach((html) => { const w = el('div', { class: 'plugin-widget' }); w.innerHTML = html; out.appendChild(w); }); area.appendChild(out); safeCreateIcons(); } }).catch(() => { /* plugins must never break rendering */ }); } } // --------------------------------------------------------------------------- // Helpers (escapeHtml imported from utils.js) // --------------------------------------------------------------------------- export function el(tag, attrs, children) { const e = document.createElement(tag); if (attrs) { Object.entries(attrs).forEach(([k, v]) => { // Skip boolean false for standard HTML boolean attributes to avoid setAttribute("checked", "false") bug if (v === false && (k === "checked" || k === "disabled" || k === "hidden" || k === "required" || k === "readonly")) { return; } e.setAttribute(k, v); }); } if (children) { children.forEach((c) => { if (c) e.appendChild(c); }); } return e; } export function icon(name, size) { const i = document.createElement("i"); i.setAttribute("data-lucide", name); i.style.width = size + "px"; i.style.height = size + "px"; i.classList.add("icon"); return i; } export function smallBadge(count) { const s = document.createElement("span"); s.className = "badge-small"; s.style.cssText = "font-size:0.68rem;color:var(--text-muted);margin-left:4px"; s.textContent = `(${count})`; return s; } function getContextMenuPositionFromElement(target) { const rect = target.getBoundingClientRect(); return { x: Math.min(rect.right - 8, window.innerWidth - 16), y: Math.min(rect.top + rect.height / 2, window.innerHeight - 16), }; } export function attachTreeItemActionButton(itemEl, vault, path, type, isReadonly) { const button = document.createElement("button"); button.type = "button"; button.className = "tree-item-action-btn"; button.setAttribute("aria-label", "Afficher le menu d’actions"); button.setAttribute("title", "Actions"); const iconEl = icon("more-vertical", 16); button.appendChild(iconEl); button.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); const pos = getContextMenuPositionFromElement(button); ContextMenuManager.show(pos.x, pos.y, vault, path, type, isReadonly); }); itemEl.appendChild(button); // Ensure Lucide icons are rendered for the button setTimeout(() => { safeCreateIcons(); }, 0); } export function attachTreeItemLongPress(itemEl, getMenuData) { let pressTimer = null; let pressHandled = false; let startX = 0; let startY = 0; const longPressDelay = 550; const moveThreshold = 10; const clearPressTimer = () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } }; itemEl.addEventListener("touchstart", (e) => { if (!e.touches || e.touches.length !== 1) return; pressHandled = false; startX = e.touches[0].clientX; startY = e.touches[0].clientY; clearPressTimer(); pressTimer = setTimeout(() => { const data = getMenuData(); if (!data) return; pressHandled = true; ContextMenuManager.show(startX, startY, data.vault, data.path, data.type, data.isReadonly); }, longPressDelay); }, { passive: true }); itemEl.addEventListener("touchmove", (e) => { if (!e.touches || e.touches.length !== 1) return; const dx = Math.abs(e.touches[0].clientX - startX); const dy = Math.abs(e.touches[0].clientY - startY); if (dx > moveThreshold || dy > moveThreshold) { clearPressTimer(); } }, { passive: true }); itemEl.addEventListener("touchend", () => { clearPressTimer(); }, { passive: true }); itemEl.addEventListener("touchcancel", () => { clearPressTimer(); }, { passive: true }); itemEl.addEventListener("click", (e) => { if (pressHandled) { e.preventDefault(); e.stopPropagation(); setTimeout(() => { pressHandled = false; }, 0); } }, true); } export function getVaultIcon(vaultName, size = 16) { const v = state.allVaults.find((val) => val.name === vaultName); const type = v ? v.type : "VAULT"; if (type === "DIR") { const i = icon("folder", size); i.style.color = "#eab308"; // yellow tint return i; } else { const purple = "#8b5cf6"; const svgNS = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("xmlns", svgNS); svg.setAttribute("width", size); svg.setAttribute("height", size); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", purple); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.classList.add("icon"); const path1 = document.createElementNS(svgNS, "path"); path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z"); const path2 = document.createElementNS(svgNS, "path"); path2.setAttribute("d", "M11 3 8 9l4 12"); const path3 = document.createElementNS(svgNS, "path"); path3.setAttribute("d", "M12 21l4-12-3-6"); const path4 = document.createElementNS(svgNS, "path"); path4.setAttribute("d", "M2 9h20"); svg.appendChild(path1); svg.appendChild(path2); svg.appendChild(path3); svg.appendChild(path4); return svg; } } function makeBreadcrumbSpan(text, onClick) { const s = document.createElement("span"); s.textContent = text; if (onClick) { s.addEventListener("click", async (event) => { event.preventDefault(); if (s.dataset.busy === "true") return; s.dataset.busy = "true"; s.style.pointerEvents = "none"; try { await onClick(event); } finally { s.dataset.busy = "false"; s.style.pointerEvents = ""; } }); } return s; } export function appendHighlightedText(container, text, query, caseSensitive) { container.textContent = ""; if (!query) { container.appendChild(document.createTextNode(text)); return; } const source = caseSensitive ? text : text.toLowerCase(); const needle = caseSensitive ? query : query.toLowerCase(); let start = 0; let index = source.indexOf(needle, start); if (index === -1) { container.appendChild(document.createTextNode(text)); return; } while (index !== -1) { if (index > start) { container.appendChild(document.createTextNode(text.slice(start, index))); } const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]); container.appendChild(mark); start = index + query.length; index = source.indexOf(needle, start); } if (start < text.length) { container.appendChild(document.createTextNode(text.slice(start))); } } export function highlightSearchText(container, text, query, caseSensitive) { container.textContent = ""; if (!query || !text) { container.appendChild(document.createTextNode(text || "")); return; } const source = caseSensitive ? text : text.toLowerCase(); const needle = caseSensitive ? query : query.toLowerCase(); let start = 0; let index = source.indexOf(needle, start); if (index === -1) { container.appendChild(document.createTextNode(text)); return; } while (index !== -1) { if (index > start) { container.appendChild(document.createTextNode(text.slice(start, index))); } const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]); container.appendChild(mark); start = index + query.length; index = source.indexOf(needle, start); } if (start < text.length) { container.appendChild(document.createTextNode(text.slice(start))); } } export function showWelcome() { hideProgressBar(); // Restore or rebuild the dashboard with tabbed sections const area = getContentArea(); // #110 — keep playing media alive if the dashboard replaces its surface. NowPlaying.handleRender(area, null); const home = document.getElementById("dashboard-home"); if (area && !home) { area.innerHTML = `
${t('common.loading')}
${t('dashboard.no_bookmarks')}

${t('dashboard.pin_hint')}

${t('dashboard.no_shares')}

${t('dashboard.shared_hint')}

`; // Re-initialize widgets and dashboard tabs if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.init(); } initDashboardTabs(); safeCreateIcons(); } else if (home) { // Dashboard already exists, show it with default tab home.style.display = ""; // Reset tabs to default document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active")); document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active")); const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]'); const defaultPanel = document.getElementById("dashboard-panel-stats"); if (defaultTab) defaultTab.classList.add("active"); if (defaultPanel) defaultPanel.classList.add("active"); } // Load all widgets (they handle missing elements gracefully) if (typeof DashboardStatsWidget !== "undefined") { DashboardStatsWidget.load(); } if (typeof DashboardConflictsWidget !== "undefined") { DashboardConflictsWidget.load(); } if (typeof DashboardRecentWidget !== "undefined") { DashboardRecentWidget.load(state.selectedContextVault); } if (typeof DashboardBookmarkWidget !== "undefined") { DashboardBookmarkWidget.load(state.selectedContextVault); } if (typeof DashboardSharedWidget !== "undefined") { DashboardSharedWidget.load(); } // Load saved searches sidebar loadSavedSearches(); } export async function loadSavedSearches() { const list = document.getElementById("saved-searches-list"); const empty = document.getElementById("saved-searches-empty"); if (!list) return; try { const searches = await api("/api/saved-searches"); // Initialise la barre de filtres même quand la liste est vide // (sinon les pills n'ont pas de listener au clic). _initSavedFilter(); if (!searches.length) { list.innerHTML = ""; if (empty) empty.style.display = ""; return; } if (empty) empty.style.display = "none"; list.innerHTML = searches.map(s => { // Detect directory-based saved search (empty query + include_paths) var isDirSearch = (!s.query || s.query.trim() === '') && s.include_paths; var displayQuery = isDirSearch ? (s.include_paths || 'Dossier') : escapeHtml(s.query); // Icon: folder-search for directories, search for regular queries var typeIcon = isDirSearch ? '' : ''; const badges = []; if (s.case_sensitive) badges.push('Aa'); if (s.whole_word) badges.push('wd'); if (s.regex) badges.push('.*'); const pathFilters = []; if (s.include_paths) pathFilters.push(`📥 ${escapeHtml(s.include_paths)}`); if (s.exclude_paths) pathFilters.push(`📤 ${escapeHtml(s.exclude_paths)}`); const vaultStr = s.vault && s.vault !== "all" ? `📁 ${escapeHtml(s.vault)}` : ""; return `
${displayQuery}
${badges.join("")} ${vaultStr} ${typeIcon}
${pathFilters.length ? '
' + pathFilters.join(" ") + '
' : ""}
`}).join(""); // Apply saved filter _applySavedFilter(); // Set up filter bar (once) _initSavedFilter(); list.querySelectorAll(".saved-search-item").forEach(item => { item.addEventListener("click", (e) => { if (e.target.classList.contains("saved-search-delete")) return; const idx = Array.from(list.children).indexOf(item); const s = searches[idx]; if (!s) return; // Apply the saved search const input = document.getElementById("search-input"); if (input) input.value = s.query; state.searchCaseSensitive = s.case_sensitive || false; state.searchWholeWord = s.whole_word || false; state.searchRegex = s.regex || false; if (typeof _updateToggleUI === "function") _updateToggleUI(); if (s.include_paths) { const incl = document.getElementById("search-include-input"); if (incl) incl.value = s.include_paths; } if (s.exclude_paths) { const excl = document.getElementById("search-exclude-input"); if (excl) excl.value = s.exclude_paths; } // Execute the search — suppress dropdown from appearing AutocompleteDropdown.hide(); AutocompleteDropdown._suppressNext = true; const vault = s.vault || "all"; if (input) { input.dispatchEvent(new Event("input")); } clearTimeout(state.searchTimeout); state.advancedSearchOffset = 0; performAdvancedSearch(s.query, vault, null); }); }); list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => { e.stopPropagation(); await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" }); loadSavedSearches(); })); safeCreateIcons(); } catch (err) { /* silently ignore */ } } export function showLoading() { const area = getContentArea(); area.innerHTML = `
Recherche en cours...
`; showProgressBar(); } export function showProgressBar() { const bar = document.getElementById("search-progress-bar"); if (bar) bar.classList.add("active"); } export function hideProgressBar() { const bar = document.getElementById("search-progress-bar"); if (bar) bar.classList.remove("active"); } function goHome() { const searchInput = document.getElementById("search-input"); if (searchInput) searchInput.value = ""; document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active")); state.currentVault = null; state.currentPath = null; state.showingSource = false; state.cachedRawSource = null; closeMobileSidebar(); showWelcome(); } // --------------------------------------------------------------------------- // SSE Client — IndexUpdateManager // --------------------------------------------------------------------------- const IndexUpdateManager = (() => { let eventSource = null; let reconnectTimer = null; let reconnectDelay = 1000; const MAX_RECONNECT_DELAY = 30000; let recentEvents = []; const MAX_RECENT_EVENTS = 20; let connectionState = "disconnected"; // disconnected | connecting | connected function connect() { if (eventSource) { eventSource.close(); } connectionState = "connecting"; _updateBadge(); eventSource = new EventSource("/api/events"); eventSource.addEventListener("connected", (e) => { connectionState = "connected"; reconnectDelay = 1000; _updateBadge(); }); eventSource.addEventListener("index_updated", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_updated", data); _onIndexUpdated(data); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_reloaded", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_reloaded", data); _onIndexReloaded(data); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("vault_added", (e) => { try { const data = JSON.parse(e.data); _addEvent("vault_added", data); showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("vault_removed", (e) => { try { const data = JSON.parse(e.data); _addEvent("vault_removed", data); showToast(`Vault "${data.vault}" supprimé`, "info"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_start", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_start", data); connectionState = "syncing"; _updateBadge(); showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info"); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_progress", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_progress", data); connectionState = "syncing"; _updateBadge(); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.addEventListener("index_complete", (e) => { try { const data = JSON.parse(e.data); _addEvent("index_complete", data); connectionState = "connected"; _updateBadge(); showToast(`Indexation terminée (${data.total_files} fichiers)`, "success"); loadVaults(); loadTags(); } catch (err) { console.error("SSE parse error:", err); } }); eventSource.onerror = () => { connectionState = "disconnected"; _updateBadge(); eventSource.close(); eventSource = null; _scheduleReconnect(); }; } function _scheduleReconnect() { if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); connect(); }, reconnectDelay); } function _addEvent(type, data) { recentEvents.unshift({ type, data, timestamp: new Date().toISOString(), }); if (recentEvents.length > MAX_RECENT_EVENTS) { recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS); } } async function _onIndexUpdated(data) { // Brief syncing state connectionState = "syncing"; _updateBadge(); const n = data.total_changes || 0; const vaults = (data.vaults || []).join(", "); // Toast removed: silent auto-indexing — no notification needed // Refresh sidebar and tags if affected vault matches current context const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault); if (affectsCurrentVault) { try { await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]); // Refresh current file if it was updated if (state.currentVault && state.currentPath) { const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath); if (changed) { openFile(state.currentVault, state.currentPath); } } } catch (err) { console.error("Error refreshing after index update:", err); } } // Refresh recent tab if it is active if (state.activeSidebarTab === "recent") { const vaultFilter = document.getElementById("recent-vault-filter"); loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } setTimeout(() => { connectionState = "connected"; _updateBadge(); }, 1500); } async function _onIndexReloaded(data) { connectionState = "syncing"; _updateBadge(); showToast(t("sync.index_reloaded"), "info"); try { await Promise.all([loadVaults(), loadTags()]); } catch (err) { console.error("Error refreshing after full reload:", err); } setTimeout(() => { connectionState = "connected"; _updateBadge(); }, 1500); } function _updateBadge() { const badge = document.getElementById("sync-badge"); if (!badge) return; badge.className = "sync-badge sync-badge--" + connectionState; const labels = { disconnected: "Déconnecté", connecting: "Connexion...", connected: "Synchronisé", syncing: t("viewer.syncing"), }; badge.title = labels[connectionState] || connectionState; } function disconnect() { if (eventSource) { eventSource.close(); eventSource = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } connectionState = "disconnected"; _updateBadge(); } function getState() { return connectionState; } function getRecentEvents() { return recentEvents; } return { connect, disconnect, getState, getRecentEvents }; })(); function initSyncStatus() { const badge = document.getElementById("sync-badge"); if (!badge) return; badge.addEventListener("click", (e) => { e.stopPropagation(); toggleSyncPanel(); }); IndexUpdateManager.connect(); } function toggleSyncPanel() { let panel = document.getElementById("sync-panel"); if (panel) { panel.remove(); return; } // Auto reconnect if disconnected when user opens the panel if (IndexUpdateManager.getState() === "disconnected") { IndexUpdateManager.connect(); } panel = document.createElement("div"); panel.id = "sync-panel"; panel.className = "sync-panel"; _renderSyncPanel(panel); document.body.appendChild(panel); // Close on outside click setTimeout(() => { document.addEventListener("click", _closeSyncPanelOutside, { once: true }); }, 0); } function _closeSyncPanelOutside(e) { const panel = document.getElementById("sync-panel"); if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") { panel.remove(); } } function _renderSyncPanel(panel) { const state = IndexUpdateManager.getState(); const events = IndexUpdateManager.getRecentEvents(); const stateLabels = { disconnected: "Déconnecté", connecting: "Connexion...", connected: "Connecté", syncing: "Synchronisation...", }; let html = `
Synchronisation ${stateLabels[state] || state}
`; if (events.length === 0) { html += `
Aucun événement récent
`; } else { html += `
`; events.slice(0, 10).forEach((ev) => { const time = new Date(ev.timestamp).toLocaleTimeString(); const typeLabels = { index_updated: t("viewer.index_updated"), index_reloaded: "Rechargement", vault_added: t("sync.vault_added_label"), vault_removed: t("sync.vault_removed_label"), index_start: t("viewer.index_start"), index_progress: t("sync.vault_indexed_label"), index_complete: "Indexation tech.", }; const label = typeLabels[ev.type] || ev.type; let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || ""; if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`; if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`; if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`; html += `
${label} ${detail} ${time}
`; }); html += `
`; } } // ── Saved searches filter ── var _savedFilterType = 'all'; var _savedFilterInitDone = false; var _savedQuery = ''; function _savedNorm(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); } function _initSavedFilter() { if (_savedFilterInitDone) return; var bar = document.getElementById('saved-filter-bar'); if (!bar) return; bar.querySelectorAll('.saved-filter-pill').forEach(function(btn) { btn.addEventListener('click', function() { bar.querySelectorAll('.saved-filter-pill').forEach(function(b) { b.classList.remove('active'); }); btn.classList.add('active'); _savedFilterType = btn.dataset.filter; _applySavedFilter(); }); }); _savedFilterInitDone = true; } /** #99 — Filter the saved-searches list by the global sidebar filter input. */ export function filterSavedSearches(query) { _savedQuery = (query || '').trim(); _applySavedFilter(); } function _applySavedFilter() { var items = document.querySelectorAll('.saved-search-item'); var list = document.getElementById('saved-searches-list'); var q = _savedNorm(_savedQuery); var hasVisible = false; items.forEach(function(item) { var type = item.dataset.type; var typeOk = _savedFilterType === 'all' || type === _savedFilterType; var textOk = !q || _savedNorm(item.textContent).includes(q); var show = typeOk && textOk; item.style.display = show ? '' : 'none'; if (show) hasVisible = true; }); // Remove a previous "no match" hint before deciding whether to add one. if (list) { var prev = list.querySelector('.sidebar-filter-empty'); if (prev) prev.remove(); } // Show/hide empty state var empty = document.getElementById('saved-searches-empty'); if (empty) empty.style.display = hasVisible ? 'none' : ''; // Explain an empty result caused by the global search rather than by an // actually empty saved-searches list. if (!hasVisible && q && list && items.length) { var hint = document.createElement('div'); hint.className = 'sidebar-filter-empty'; hint.textContent = t('sidebar.no_results'); list.appendChild(hint); } }