/* ObsiGate — Viewer module */ import { api, AuthManager } from './auth.js'; import { state } from './state.js'; import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js'; import { isInlineEditorActive, queryEditor } from './editor-inline.js'; import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js'; import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js'; import { AutocompleteDropdown, performAdvancedSearch } from './search.js'; import { initDashboardTabs } from './sync.js'; import { renderMermaidBlocks } from './mermaid-viewer.js'; import { renderExcalidraw } from './excalidraw-viewer.js'; import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js'; import { openShareDialog } from './config.js'; import { cacheViewedFile, getCachedFile } from './offline.js'; import { t } from './i18n.js'; import { onFileRender } from './plugins.js'; import { NowPlaying } from './now-playing.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. export function renderXlsxViewer(area, data) { const sheets = data.xlsx_sheets || []; const lossy = data.xlsx_lossy_features || []; // 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; const tabs = sheets.length > 1 ? `
${sheets.map((s, i) => `` ).join("")}
` : ""; const panels = 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"))}
` : ""; area.innerHTML = `
${tabs}
${lossWarning}
${panels}
`; const saveBtn = area.querySelector("#xlsx-save-btn"); const panelEls = [...area.querySelectorAll(".xlsx-panel")]; const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length; const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; }; // Editable cells: Enter blurs, Escape reverts, paste stays single-line. area.querySelectorAll(".xlsx-table td").forEach((td) => { td.contentEditable = "true"; td.spellcheck = false; td.dataset.orig = td.textContent; td.addEventListener("input", () => { td.classList.add("xlsx-dirty"); refreshSaveState(); }); td.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); td.blur(); } if (e.key === "Escape") { td.textContent = td.dataset.orig; td.classList.remove("xlsx-dirty"); refreshSaveState(); } }); td.addEventListener("paste", (e) => { e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " "); document.execCommand("insertText", false, text); }); }); 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. const formulaBtn = area.querySelector("#xlsx-formula-btn"); formulaBtn.addEventListener("click", () => { allowFormula = !allowFormula; formulaBtn.setAttribute("aria-pressed", String(allowFormula)); formulaBtn.classList.toggle("active", allowFormula); }); const putSheet = (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(", "); if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) 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; }); 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 { 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"); }); 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 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); } }