Files
ObsiGate/frontend/js/viewer.js
T
bruno a50227849d
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 1m57s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
CI / test (push) Canceled after 1h50m30s
feat: onglet de navigation avec facettes, tris et retour Home complet #158
- clic sur un repertoire de l'arbre (modes focus vault et All) ouvre un onglet de navigation dedie qui coexiste avec les onglets de fichiers ouverts
- vue: sous-repertoires cliquables, facettes Vaults/Tags/Extensions (meme mecanisme que la recherche), tris Pertinence/Date, bouton Sauver; tags indexes exposes par GET /api/vault/{v}/files
- BUG-100: showWelcome re-injecte la section Raccourcis & Astuces et goHome efface la recherche globale + le filtre de la sidebar
2026-10-02 12:27:27 -04:00

4755 lines
189 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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';
import { openContextMenu, closeContextMenu } from './xlsx/context-menu.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 = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
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 = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier — vous etes hors-ligne et ce fichier n\'est pas en cache.</p></div>';
}
}
}
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) => `<span>${i + 1}</span>`).join("");
return `<div class="pretty-view"><div class="pp-gutter" aria-hidden="true">${gutter}</div><pre class="pp-pre"><code class="language-${lang}">${escapeHtml(text)}</code></pre></div>`;
}
// 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 ? `<span class="${cls}">${escapeHtml(m[0])}</span>` : 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 <img> / thumbnails. */
export function buildImageUrl(vault, path) {
return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function buildThumbUrl(vault, path, size) {
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
}
// #156-A10 — per-file undo/redo history. The stacks must outlive a re-render
// (a structure save rebuilds the whole viewer) without leaking across files, so
// they live in a small bounded module-level store keyed by vault + path.
const _xlsxHistory = new Map();
const XLSX_HISTORY_MAX_FILES = 8;
function xlsxHistory(key) {
let entry = _xlsxHistory.get(key);
if (!entry) {
entry = { undo: [], redo: [] };
_xlsxHistory.set(key, entry);
}
if (_xlsxHistory.size > XLSX_HISTORY_MAX_FILES) {
for (const k of _xlsxHistory.keys()) {
if (k !== key) { _xlsxHistory.delete(k); break; }
}
}
return entry;
}
/** Clear the per-file spreadsheet undo/redo history (tests, file close). */
export function resetXlsxHistory() {
_xlsxHistory.clear();
}
// #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 `<div class="xlsx-truncated" role="note">
<i data-lucide="scissors" class="xlsx-truncated-icon"></i>
<div class="xlsx-warning-body">
<strong>${escapeHtml(t("xlsx.truncated_title"))}</strong>
<span>${escapeHtml(reasons.join(" "))}</span>
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.truncated_hint"))}</span>
</div>
</div>`;
}
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;
// #156-A12 — revision of the file as last read or written. It rides every
// write as `if_match`: a write on a file that changed elsewhere is refused
// with 409 `conflict` instead of silently overwriting the other writer.
let revision = data.xlsx_revision || null;
// #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;
// #156-A10 — the undo/redo stacks are kept per file (module-level store).
const historyKey = `${data.vault || ""}\u0000${data.path || ""}`;
// #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 <table>).
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
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
: sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
).join("");
const lossWarning = lossy.length
? `<div class="xlsx-warning" role="note">
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
<div class="xlsx-warning-body">
<strong>${escapeHtml(t("xlsx.lossy_title"))}</strong>
<span class="xlsx-warning-list">${lossy
.map((f) => `<span class="xlsx-warning-tag">${escapeHtml(t("xlsx.feature_" + f))}</span>`)
.join("")}</span>
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.lossy_hint"))}</span>
</div>
</div>`
: "";
// #154-A1 — status pills and the grouped command bar are built by
// buildCommandBar() (frontend/js/xlsx/command-bar.js).
area.innerHTML = `
<div class="xlsx-viewer">
<div class="xlsx-toolbar" role="toolbar">
<div class="xlsx-cmdbar">
${tabs}
<span class="xlsx-toolbar-actions">${actionsHtml}</span>
</div>
${statusBar}
</div>
${lossWarning}
<div class="xlsx-formula-bar">
<input type="text" class="xlsx-active-cell" id="xlsx-active-cell" value=""
spellcheck="false" autocomplete="off" placeholder="A1"
aria-label="${escapeHtml(t("xlsx.name_box_label"))}"
title="${escapeHtml(t("xlsx.name_box_hint"))}" />
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
spellcheck="false" list="xlsx-function-list" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
<datalist id="xlsx-function-list"></datalist>
<span class="xlsx-find-group">
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
<span class="xlsx-find-count" id="xlsx-find-count"></span>
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
</button>
</span>
</div>
<div class="xlsx-body">
<div class="xlsx-main">
<div class="xlsx-panels">${panels}</div>
</div>
<aside class="xlsx-inspector" id="xlsx-inspector" aria-hidden="true">
<div class="xlsx-inspector-resize" id="xlsx-inspector-resize" role="separator" aria-orientation="vertical" title="${escapeHtml(t("xlsx.inspector_resize"))}"></div>
<div class="xlsx-inspector-head">
<span class="xlsx-inspector-title">${escapeHtml(t("xlsx.inspector_title"))}</span>
<span class="xlsx-inspector-actions">
<button class="btn-action xlsx-inspector-ai" id="xlsx-inspector-ai" type="button" title="${escapeHtml(t("xlsx.inspector_ai"))}">
<i data-lucide="bot" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.inspector_ai_short"))}
</button>
<button class="btn-action xlsx-inspector-close" id="xlsx-inspector-close" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>
</span>
</div>
<div class="xlsx-inspector-body" id="xlsx-inspector-body"></div>
</aside>
</div>
</div>`;
// A re-render invalidates any open context menu (it targets the old DOM).
closeContextMenu();
// ── #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 = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
}
});
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 = `<i data-lucide="git-merge" class="xlsx-banner-icon"></i>
<span class="xlsx-banner-text">${escapeHtml(msg)}</span>
<button class="btn-action xlsx-banner-retry" type="button">${escapeHtml(t("xlsx.conflict_retry"))}</button>
<button class="btn-action xlsx-banner-dismiss" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>`;
// #156-A12 — the file changed elsewhere: re-read it first so the retry
// carries the CURRENT revision instead of failing on the same stale token.
banner.querySelector(".xlsx-banner-retry").addEventListener("click", async () => {
bannerHost.innerHTML = "";
try {
const fresh = await api(
`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
);
if (fresh && fresh.xlsx_revision) revision = fresh.xlsx_revision;
} catch (_) { /* keep the previous token: the retry is refused again */ }
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(`<table>${win.html}</table>`, "text/html");
// No `tbody` selector: the fragment embeds its own wrapper div, so the
// parse yields bare `<tr>` inside `<table>` (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
// #155 — Excel-like range selection: { panel, anchor, focus } in (row, col).
let selection = null;
let suppressSelectionReset = false;
const cellName = (td) => td?.dataset.cell || "";
const clearSelectionClasses = () => {
const host = selection && selection.panel ? selection.panel : area;
host.querySelectorAll("td.xlsx-selected").forEach((c) => c.classList.remove("xlsx-selected"));
};
const selectionBounds = () => {
if (!selection) return null;
const { anchor, focus } = selection;
return {
r1: Math.min(anchor.row, focus.row),
r2: Math.max(anchor.row, focus.row),
c1: Math.min(anchor.col, focus.col),
c2: Math.max(anchor.col, focus.col),
};
};
const rangeLabel = () => {
const b = selectionBounds();
if (!b) return "";
const first = `${columnName(b.c1)}${b.r1}`;
return b.r1 === b.r2 && b.c1 === b.c2 ? first : `${first}:${columnName(b.c2)}${b.r2}`;
};
// Paint the current selection and show its address in the Name Box.
const applySelection = () => {
if (!selection) return;
const { panel } = selection;
const b = selectionBounds();
panel.querySelectorAll("td.xlsx-selected").forEach((c) => c.classList.remove("xlsx-selected"));
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
if (td) td.classList.add("xlsx-selected");
}
}
activeCellEl.value = rangeLabel();
};
const setSelection = (anchorTd, focusTd = anchorTd) => {
if (!anchorTd) { clearSelectionClasses(); selection = null; return; }
const panel = anchorTd.closest(".xlsx-panel");
const anchor = parseRef(cellName(anchorTd));
const focus = parseRef(cellName(focusTd));
if (!panel || !anchor || !focus) return;
selection = { panel, anchor, focus };
applySelection();
};
const inSelection = (td) => {
const b = selectionBounds();
const p = parseRef(cellName(td));
if (!b || !p || !selection || selection.panel !== td.closest(".xlsx-panel")) return false;
return p.row >= b.r1 && p.row <= b.r2 && p.col >= b.c1 && p.col <= b.c2;
};
const setActiveCell = (td) => {
activeTd = td || null;
if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
if (!td) {
activeCellEl.value = "";
formulaInput.value = "";
formulaInput.disabled = true;
setSelection(null);
return;
}
td.classList.add("xlsx-active");
activeCellEl.value = cellName(td);
formulaInput.disabled = false;
formulaInput.value = td.textContent;
setSelection(td);
};
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, extend = false) => {
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
// #155 — Shift+arrow extends the selection from the current anchor.
if (extend && selection && selection.panel === panel) {
suppressSelectionReset = true;
next.focus();
suppressSelectionReset = false;
selection.focus = { row, col };
applySelection();
activeTd = next;
formulaInput.value = next.textContent;
return;
}
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();
}
});
// ── #156-A10 — undo/redo unifié ──────────────────────────
// One stack for everything the viewer can reverse: a cell edit (typed,
// pasted, cleared), a row sort, and the REVERSIBLE structure actions
// (insert, rename, duplicate, add sheet — a delete is not replayable, the
// `.bak` stays the safety net).
let pendingEdit = null;
const undoBtn = area.querySelector("#xlsx-undo-btn");
const redoBtn = area.querySelector("#xlsx-redo-btn");
const fileHistory = xlsxHistory(historyKey);
const undoStack = fileHistory.undo;
const redoStack = fileHistory.redo;
const updateUndoButtons = () => {
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
};
const pushHistory = (entry) => {
undoStack.push(entry);
redoStack.length = 0;
updateUndoButtons();
};
/** Sheet identity of a panel: its name when known (a CSV has none). */
const panelKey = (panel) => {
const idx = Number(panel?.dataset.sheet);
return sheets[idx]?.name || String(Number.isFinite(idx) ? idx : 0);
};
/** Reverse lookup: a panel by sheet name, then by rendered index. */
const panelForKey = (key) => {
const byName = sheets.findIndex((s) => s.name === key);
if (byName >= 0) return panelEls[byName];
return panelEls.find((p) => p.dataset.sheet === String(key)) || null;
};
const rowNumOf = (tr) => Number(tr.querySelector("th.xlsx-rownum")?.textContent);
const rowNums = (list) => list.map(rowNumOf);
// One history entry per changed cell: shared by the focus/blur edit cycle
// and by the range operations (paste, cut, clear) so they can all be undone.
const recordEdit = (td, before, after) => {
if (before === after) return;
pushHistory({ kind: "cell", sheet: panelKey(td.closest(".xlsx-panel")), ref: td.dataset.cell, before, after });
};
const commitEdit = () => {
if (!pendingEdit) return;
const { td, before } = pendingEdit;
pendingEdit = null;
recordEdit(td, before, td.textContent);
};
const beginEdit = (td) => {
if (pendingEdit && pendingEdit.td !== td) commitEdit();
if (!pendingEdit) pendingEdit = { td, before: td.textContent };
};
const applyCellHistory = (entry, value) => {
const panel = panelForKey(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();
};
/** Replay a recorded row order (undo/redo of a sort). */
const applyRowOrder = (entry, order) => {
const tbody = panelForKey(entry.sheet)?.querySelector(".xlsx-table tbody");
if (!tbody || !order) return;
const byNum = new Map([...tbody.querySelectorAll("tr")].map((tr) => [rowNumOf(tr), tr]));
if (order.length !== byNum.size || order.some((n) => !byNum.has(n))) return;
order.forEach((n) => tbody.appendChild(byNum.get(n)));
};
/** Replay a recorded filter visibility (and its needle). */
const applyRowVisibility = (entry, side) => {
const panel = panelForKey(entry.sheet);
const hidden = new Set(entry[side]);
if (panel) {
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
tr.style.display = hidden.has(rowNumOf(tr)) ? "none" : "";
});
}
findInput.value = entry[`${side}Needle`] || "";
};
/** Replay a structure batch through the same endpoint as the UI. */
const applyStructureHistory = async (entry, side) => {
const actions = entry[side];
if (!actions?.length) return;
try {
await putStructure(actions, false, { quiet: true, record: false });
} catch (err) {
await structureError(err, actions);
}
};
const applyEntry = (entry, side) => {
if (entry.kind === "cell") return applyCellHistory(entry, entry[side]);
if (entry.kind === "order") return applyRowOrder(entry, entry[side]);
if (entry.kind === "hidden") return applyRowVisibility(entry, side);
if (entry.kind === "structure") return applyStructureHistory(entry, side);
return undefined;
};
const undo = () => {
const entry = undoStack.pop();
if (!entry) return;
applyEntry(entry, "before");
redoStack.push(entry);
updateUndoButtons();
};
const redo = () => {
const entry = redoStack.pop();
if (!entry) return;
applyEntry(entry, "after");
undoStack.push(entry);
updateUndoButtons();
};
if (undoBtn) undoBtn.addEventListener("click", undo);
if (redoBtn) redoBtn.addEventListener("click", redo);
// The command bar always renders the buttons disabled: re-enable them when
// the history survived a re-render.
updateUndoButtons();
// #156-A6 — undo/redo, save, clipboard and grid navigation all live in the
// grid-level keydown handler registered below (one place, one priority
// order); the cell handler only keeps the in-cell keys.
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", () => {
// #156-A6 — typing puts the cell in “edit” mode: Delete then deletes a
// character instead of clearing the whole selection (spreadsheet rule).
td.dataset.editing = "1";
td.classList.add("xlsx-dirty");
syncFormulaBar(td);
refreshSaveState();
});
td.addEventListener("focus", () => {
td.dataset.editing = "";
beginEdit(td);
// #155 — a range extension keeps the selection; only the bar follows.
if (suppressSelectionReset) {
activeTd = td;
activeCellEl.value = cellName(td);
formulaInput.disabled = false;
formulaInput.value = td.textContent;
return;
}
setActiveCell(td);
});
td.addEventListener("blur", () => { commitEdit(); td.dataset.editing = ""; });
td.addEventListener("keydown", (e) => {
const mod = e.ctrlKey || e.metaKey;
// #156-A6 — keys moved to the grid handler when combined with Ctrl
// (Ctrl+flèches = jump to the edge of a data run, Ctrl+Home/End…).
const move = e.key === "Tab" || e.key.startsWith("Arrow");
if (move && mod) return;
if (e.key === "Enter" && e.shiftKey) {
// #156-A6 — Maj+Entrée: line break inside the cell (plain text only).
e.preventDefault();
insertTextAtCaret(td, "\n");
return;
}
if (e.key === "F2") { e.preventDefault(); startEditing(td); return; }
if (e.key === "Delete" && !mod && !isTypingInCell(td)) {
// #156-A6 — Suppr clears the selection when the cell is not edited.
e.preventDefault();
const ref = parseRef(cellName(td));
if (!ref) return;
const panel = td.closest(".xlsx-panel");
const b = selection && selection.panel === panel
? selectionBounds()
: { r1: ref.row, r2: ref.row, c1: ref.col, c2: ref.col };
clearRange(panel, b);
return;
}
if (e.key.length === 1 && !mod) td.dataset.editing = "1";
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
if (e.key === "Escape") {
td.textContent = td.dataset.orig;
td.classList.remove("xlsx-dirty");
td.dataset.editing = "";
syncFormulaBar(td);
refreshSaveState();
}
if (move) {
// 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, e.shiftKey && e.key.startsWith("Arrow"));
}
});
td.addEventListener("paste", (e) => {
// #156-A5 — a multi-cell block fills a range starting at this cell; a
// single value replaces the cell (or is inserted at the caret when the
// user has a live text selection). Plain text only, never HTML (R4).
const payload = clipTextOf(e) || (rangeClipboard ? toTsv(rangeClipboard.rows) : "");
if (!payload) return;
e.preventDefault();
applyPaste(payload, td);
});
};
// #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);
});
/** Remember the revision a write returned (chained writes stay in sync). */
const keepRevision = (res) => {
if (res && typeof res.revision === "string" && res.revision) revision = res.revision;
return res;
};
const putSheet = isCsv
? async (job, force) => keepRevision(await 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, if_match: revision }),
},
))
: async (job, force) => keepRevision(await 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, if_match: revision }),
},
));
saveBtn.addEventListener("click", async () => {
// One PUT per sheet (dirty cells can span tabs before a save).
// BUG-096 — a CSV is served as a single `html` table, with no
// `xlsx_sheets`: reading the sheet name from `sheets[…]` threw a
// TypeError before any request went out, so a CSV edit could never be
// saved. Fall back to the file name (the CSV endpoint ignores it).
const sheetNameOf = (panel) => {
const idx = Number(panel.dataset.sheet);
return sheets[idx]?.name || sheets[0]?.name || data.title || data.path || "";
};
const jobs = panelEls
.map((panel) => {
const cells = {};
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
return { sheet: sheetNameOf(panel), cells };
})
.filter((job) => Object.keys(job.cells).length);
if (!jobs.length) return;
// #156-A9 — the viewer has no formula engine: a value starting with "=" or
// "@" is stored as TEXT unless the f(x) toggle is on. Say so at save time
// instead of letting the user believe a formula was written (the workbook
// keeps Excel's own computed values as the source of truth).
if (!allowFormula && !isCsv) {
const asText = jobs.reduce(
(n, job) => n + Object.values(job.cells).filter((v) => typeof v === "string" && /^[=@]/.test(v.trim())).length,
0,
);
if (asText) showToast(t("xlsx.formula_stored_as_text", { n: asText }), "info");
}
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 <tr> 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 orderBefore = rowNums(rows);
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 = "";
// #156-A10 — the view change is undoable (the workbook stays untouched).
const orderAfter = rowNums(rows);
if (orderBefore.join() !== orderAfter.join()) {
pushHistory({ kind: "order", sheet: panelKey(panel), before: orderBefore, after: orderAfter });
}
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
};
// #155 — Excel-like: clicking a column/row header selects the whole
// column/row. Sorting moved to the context menu (and the sort reset button).
const selectColumn = (panel, col) => {
const rows = panel.querySelectorAll("th.xlsx-rownum").length;
if (!rows || !col) return;
selection = { panel, anchor: { row: 1, col }, focus: { row: rows, col } };
applySelection();
const td = findTd(panel, 1, col);
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = td.textContent; }
};
const selectRow = (panel, row) => {
const cols = panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length;
if (!row || !cols) return;
selection = { panel, anchor: { row, col: 1 }, focus: { row, col: cols } };
applySelection();
const td = findTd(panel, row, 1);
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = td.textContent; }
};
const wireHeaderSelection = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table || table.dataset.selectWired) return;
table.dataset.selectWired = "1";
table.querySelectorAll("thead th:not(.xlsx-corner)").forEach((th) => {
th.style.cursor = "pointer";
th.title = t("xlsx.select_column");
th.addEventListener("click", () => selectColumn(panel, [...th.parentElement.children].indexOf(th)));
});
table.querySelectorAll("th.xlsx-rownum").forEach((th) => {
th.style.cursor = "pointer";
th.title = t("xlsx.select_row");
th.addEventListener("click", () => selectRow(panel, Number(th.textContent)));
});
};
const hiddenRowsOf = (panel) =>
[...panel.querySelectorAll(".xlsx-table tbody tr")]
.filter((tr) => tr.style.display === "none")
.map(rowNumOf);
// Filter: rows whose cell text lacks the needle are hidden. Recorded as ONE
// coalesced undo entry per typing session (#156-A10), so Ctrl+Z clears the
// whole filter instead of replaying it keystroke by keystroke.
const applyFilter = (panel, needle) => {
const before = hiddenRowsOf(panel);
const beforeNeedle = panel.dataset.filterNeedle || "";
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 = "";
panel.dataset.filterNeedle = needle;
if (panel !== visiblePanel()) return; // one entry for the sheet on screen
const after = hiddenRowsOf(panel);
const sheet = panelKey(panel);
const top = undoStack[undoStack.length - 1];
if (top && top.kind === "hidden" && top.sheet === sheet) {
top.after = after;
top.afterNeedle = needle;
redoStack.length = 0;
updateUndoButtons();
return;
}
if (!before.length && !after.length) return; // nothing hidden yet
pushHistory({ kind: "hidden", sheet, before, after, beforeNeedle, afterNeedle: needle });
};
panelEls.forEach(wireHeaderSelection);
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 = "";
};
// #156-A11 — the find scans EVERY sheet, not just the visible one: the
// counter adds the number of sheets holding a hit and stepping onto a match
// brings its tab forward (the match would otherwise stay invisible).
const runFind = () => {
clearFind();
const needle = findInput.value;
if (!needle.trim()) return;
const hay = findCase ? (s) => s : (s) => s.toLowerCase();
const n = hay(needle);
panelEls.forEach((panel) => {
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 <mark> 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, panel });
}
});
});
if (!findMatches.length) {
findCount.textContent = t("xlsx.find_no_match");
return;
}
focusMatch(0);
};
/** Show the find counter, mentioning the sheets when hits span several. */
const updateFindCount = (index) => {
const count = findMatches.length;
const sheets = new Set(findMatches.map((m) => m.panel)).size;
findCount.textContent = sheets > 1
? t("xlsx.find_count_sheets", { index, count, sheets })
: t("xlsx.find_count", { index, count });
};
const focusMatch = (i) => {
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
findIndex = (i + findMatches.length) % findMatches.length;
const { td, mark, panel } = findMatches[findIndex];
mark.classList.add("xlsx-find-current");
// A hit in another sheet: activate its tab so the match becomes visible.
if (panel.style.display === "none") {
const idx = panel.dataset.sheet;
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x.dataset.sheet === idx));
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
}
// scrollIntoView is missing in JSDOM; guard it (real browsers have it).
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
updateFindCount(findIndex + 1);
};
// 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;
/** Inverse of a structure action, or null when it cannot be replayed. */
const inverseAction = (action) => {
switch (action.op) {
case "row_insert":
return { op: "row_delete", sheet: action.sheet, at: action.at, count: action.count || 1 };
case "col_insert":
return { op: "col_delete", sheet: action.sheet, at: action.at, count: action.count || 1 };
case "sheet_rename":
return { op: "sheet_rename", from: action.to, to: action.from };
case "sheet_add":
return String(action.name || "").length && String(action.name).length <= 31
? { op: "sheet_delete", name: action.name }
: null;
case "sheet_duplicate":
return String(action.as || "").length && String(action.as).length <= 31
? { op: "sheet_delete", name: action.as }
: null;
default:
return null; // row_delete / col_delete / sheet_delete drop data
}
};
const putStructure = async (actions, force = false, { quiet = false, record = true } = {}) => {
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, if_match: revision }),
},
);
if (!quiet) showToast(t("xlsx.structure_saved"), "success");
// #156-A10 — a fully reversible batch goes on the undo stack.
if (record) {
const inverses = actions.map(inverseAction);
if (inverses.every(Boolean)) {
pushHistory({ kind: "structure", before: inverses, after: actions });
}
}
// 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);
return 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;
}
if (err && err.code === "conflict") {
// #156-A12 — a concurrent writer: keep the user's edits, offer a retry.
showConflict(t("xlsx.conflict_msg"));
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);
});
// ── #156-A11 — sortie : CSV / Markdown / HTML / impression ────────────
// The four formats all read the SAME grid: the selection's block when a
// multi-cell range is active (this tranche l'écart doc/code de #153 A13,
// qui annonçait « export de la sélection »), the visible sheet otherwise.
// Values are the DISPLAYED ones — the cached formula shadow is a tooltip,
// never data. Rows follow the render (post-trim/post-lazy-load) so what is
// exported is exactly what the user sees.
/** The whole visible sheet as a matrix of displayed values. */
const sheetMatrix = (panel) =>
[...panel.querySelectorAll(".xlsx-table tbody tr")].map((tr) =>
[...tr.querySelectorAll("td")].map((td) => cellTextOf(td)),
);
/** What an export should carry, plus the scope/title to name the file. */
const exportPayload = () => {
const panel = visiblePanel();
const b = selectionBounds();
const selected = Boolean(
b && selection && selection.panel === panel && (b.r2 > b.r1 || b.c2 > b.c1),
);
const base = (sheets[Number(panel.dataset.sheet)]?.name || data.title || data.path || "feuille")
.replace(/\.[^.]+$/, "");
return {
rows: selected ? selectionMatrix() : sheetMatrix(panel),
selected,
// « Fruits-A1C3 » — a filename-safe version of the range address.
name: selected ? `${base}-${rangeLabel().replace(/[:$]/g, "")}` : base,
scope: t(selected ? "xlsx.export_scope_selection" : "xlsx.export_scope_sheet"),
};
};
const downloadText = (filename, text, mime) => {
const blob = new Blob([text], { type: mime });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
};
const toCsv = (rows) =>
rows
.map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";"))
.join("\n");
const toMarkdown = (rows) => {
if (!rows.length) return "";
const head = rows[0];
return [
`| ${head.join(" | ")} |`,
`|${head.map(() => "---").join("|")}|`,
...rows.slice(1).map((r) => `| ${r.join(" | ")} |`),
].join("\n");
};
const toHtml = (rows, title) => `<!DOCTYPE html>
<html lang="${document.documentElement.lang || "fr"}">
<head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head>
<body><table border="1"><tbody>${rows
.map((r) => `<tr>${r.map((v) => `<td>${escapeHtml(v)}</td>`).join("")}</tr>`)
.join("")}</tbody></table></body></html>`;
area.querySelector("#xlsx-csv-btn").addEventListener("click", () => {
const { rows, name, scope } = exportPayload();
downloadText(`${name}.csv`, `\uFEFF${toCsv(rows)}`, "text/csv;charset=utf-8");
showToast(t("xlsx.export_done", { scope }), "success");
});
const exportDocument = (kind) => {
const { rows, name, scope } = exportPayload();
if (kind === "md") {
downloadText(`${name}.md`, toMarkdown(rows), "text/markdown;charset=utf-8");
} else {
downloadText(`${name}.html`, toHtml(rows, name), "text/html;charset=utf-8");
}
showToast(t("xlsx.export_done", { scope }), "success");
};
// Print renders the sheet in a hidden 0×0 iframe so the app chrome (toolbar,
// banners, tabs, inspector) never reaches the paper — the frame carries its
// own standalone document. The previous frame is dropped first: at most one
// ever lingers in the page.
const printSheet = () => {
const { rows, name } = exportPayload();
document.querySelectorAll("body > iframe.xlsx-print-frame").forEach((f) => f.remove());
document.body.classList.add("xlsx-print");
const frame = document.createElement("iframe");
frame.className = "xlsx-print-frame";
frame.setAttribute("aria-hidden", "true");
document.body.appendChild(frame);
try {
const doc = frame.contentDocument;
if (doc) {
doc.open();
doc.write(toHtml(rows, name));
doc.close();
const win = frame.contentWindow;
if (win && typeof win.print === "function") win.print();
} else {
// No same-origin contentDocument (older engines): keep the document
// reachable for a manual print instead of losing the export.
frame.setAttribute("srcdoc", toHtml(rows, name));
if (typeof window.print === "function") window.print();
}
} finally {
document.body.classList.remove("xlsx-print");
}
};
const exportMenu = area.querySelector("#xlsx-export-menu");
if (exportMenu) {
area.querySelector("#xlsx-export-btn").addEventListener("click", () => {
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
});
exportMenu.querySelectorAll("[data-xlsx-export]").forEach((item) => {
item.addEventListener("click", () => {
exportMenu.style.display = "none";
if (item.dataset.xlsxExport === "print") printSheet();
else exportDocument(item.dataset.xlsxExport);
});
});
} // ── #156-A8 — mise en forme en écriture ──────────────────────────────────
// Styles, fusions, largeurs, hauteurs et volets figés partent en une seule
// réécriture verrouillée/atomique (PUT …/xlsx/style), comme les éditions de
// cellules et la structure. Les couleurs sont **choisies** (sélecteur natif),
// jamais codées en dur : le champ `style` ne porte que des valeurs de l'appel.
const selectionRange = () => {
const b = selectionBounds();
if (b && selection && selection.panel === visiblePanel()) return rangeLabel();
return cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null) || "A1";
};
const putStyle = async (ops, force = false) => {
const res = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/style?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ops, force, if_match: revision }),
},
);
showToast(t("xlsx.format_applied"), "success");
// Re-render from the server: the style maps (and the revision) are fresh.
const fresh = await api(
`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
);
renderXlsxViewer(area, fresh);
return fresh;
};
const styleError = async (err, retryOps) => {
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 putStyle(retryOps, true); // re-emitted with force
showToast(t("xlsx.lossy_cancelled"), "info");
return null;
}
if (err && err.code === "conflict") {
showConflict(t("xlsx.conflict_msg"));
return null;
}
showToast(`${t("xlsx.format_error")}: ${err.message || err}`, "error");
return null;
};
const runStyle = async (ops) => {
try { await putStyle(ops); } catch (err) { await styleError(err, ops); }
};
// The bold/italic toggles read the style the reader applied (A15 keeps the
// font fragments inline), so a second click removes the effect.
const activeStyleCell = () =>
(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
const hasBold = () => {
const td = activeStyleCell();
return Boolean(td && (td.style.fontWeight === "600" || td.style.fontWeight === "bold"));
};
const hasItalic = () => {
const td = activeStyleCell();
return Boolean(td && td.style.fontStyle === "italic");
};
const hasUnderline = () => {
const td = activeStyleCell();
return Boolean(td && td.style.textDecoration === "underline");
};
const formatBtn = area.querySelector("#xlsx-format-btn");
if (formatBtn) formatBtn.addEventListener("click", () => {
const old = area.querySelector(".xlsx-format-menu");
if (old) { old.remove(); return; }
const sheetName = sheets[visibleSheetIndex()]?.name || "";
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu xlsx-format-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);
};
const sep = () => menu.appendChild(
Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }),
);
const cellStyle = (style) => () => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style },
]);
const numFormat = (fmt) => cellStyle({ number_format: fmt });
item(t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
item(t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
item(t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
item(t("xlsx.format_clear"), cellStyle({
bold: false, italic: false, underline: false,
font_color: "", fill_color: "", align: "left", number_format: "General",
}));
sep();
item(t("xlsx.format_align_left"), cellStyle({ align: "left" }));
item(t("xlsx.format_align_center"), cellStyle({ align: "center" }));
item(t("xlsx.format_align_right"), cellStyle({ align: "right" }));
sep();
// Colours come from the native picker; applied once when it closes.
const colour = (label, apply) => {
const row = document.createElement("label");
row.className = "xlsx-format-colour";
const span = document.createElement("span");
span.textContent = label;
const input = document.createElement("input");
input.type = "color";
input.addEventListener("change", apply);
row.appendChild(span);
row.appendChild(input);
menu.appendChild(row);
};
colour(t("xlsx.format_font_color"), (e) => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { font_color: e.target.value } },
]));
colour(t("xlsx.format_fill_color"), (e) => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { fill_color: e.target.value } },
]));
sep();
item(t("xlsx.format_num_general"), numFormat("General"));
item(t("xlsx.format_num_number"), numFormat("0.00"));
item(t("xlsx.format_num_percent"), numFormat("0.0%"));
item(t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
item(t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
item(t("xlsx.format_num_text"), numFormat("@"));
sep();
item(t("xlsx.format_merge"), () => {
const b = selectionBounds();
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
showToast(t("xlsx.format_merge_needs_range"), "info");
return;
}
runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]);
});
item(t("xlsx.format_unmerge"), () => {
runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]);
});
sep();
item(t("xlsx.format_freeze"), () => {
runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]);
});
item(t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
sep();
item(t("xlsx.format_col_width"), async () => {
const b = selectionBounds();
const cols = [];
if (b && selection && selection.panel === visiblePanel()) {
for (let c = b.c1; c <= b.c2; c++) cols.push(columnName(c));
} else {
const ref = parseRef(selectionRange().split(":")[0]) || { col: 1 };
cols.push(columnName(ref.col));
}
const answer = await showPrompt({
title: t("xlsx.format_col_width"),
message: t("xlsx.format_col_prompt", { col: cols.join(", ") }),
value: "20",
});
const width = Number(String(answer || "").replace(",", "."));
if (!answer || !Number.isFinite(width) || width < 0 || width > 255) return;
runStyle(cols.map((col) => ({ op: "col_width", sheet: sheetName, col, width })));
});
item(t("xlsx.format_row_height"), async () => {
const b = selectionBounds();
const rows = [];
if (b && selection && selection.panel === visiblePanel()) {
for (let r = b.r1; r <= b.r2; r++) rows.push(r);
} else {
const ref = parseRef(selectionRange().split(":")[0]) || { row: 1 };
rows.push(ref.row);
}
const answer = await showPrompt({
title: t("xlsx.format_row_height"),
message: t("xlsx.format_row_prompt", { row: rows.join(", ") }),
value: "24",
});
const height = Number(String(answer || "").replace(",", "."));
if (!answer || !Number.isFinite(height) || height < 0 || height > 409) return;
runStyle(rows.map((row) => ({ op: "row_height", sheet: sheetName, row, height })));
});
formatBtn.closest(".xlsx-toolbar").appendChild(menu);
});
// ══ #155 — Excel-like selection & context menu ═════════════════════════
const viewerEl = area.querySelector(".xlsx-viewer");
// ══ #156-A5 — presse-papiers de plage (copier / couper / coller un bloc) ══
// The grid keeps its own TSV clipboard so a copy → paste round-trip works
// even when the system clipboard is unavailable; the async Clipboard API is
// a best-effort mirror and the fallback source for a block that did not come
// from this grid (e.g. one copied in Excel). Values are always inserted as
// plain text — never HTML (risk R4).
let rangeClipboard = null; // { panel, r1, r2, c1, c2, rows, cut }
let pendingCut = null; // cut source range, cleared by the next paste
/** Displayed text of a cell (the cached-value shadow is a tooltip, not data). */
const cellTextOf = (td) => {
if (!td) return "";
const clone = td.cloneNode(true);
clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
return clone.textContent;
};
const toTsv = (rows) => rows.map((line) => line.join("\t")).join("\n");
/** The selected range as a matrix of displayed values. */
const selectionMatrix = () => {
const b = selectionBounds();
if (!b || !selection) return null;
const rows = [];
for (let r = b.r1; r <= b.r2; r++) {
const line = [];
for (let c = b.c1; c <= b.c2; c++) line.push(cellTextOf(findTd(selection.panel, r, c)));
rows.push(line);
}
return rows;
};
/** Best-effort system-clipboard write (silently ignored when unavailable). */
const writeClipboard = async (text) => {
try {
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (_) { /* restricted clipboard: the internal one still works */ }
return false;
};
/** Copy the selected range. Returns false when there is nothing to copy. */
const copySelection = () => {
const b = selectionBounds();
const rows = selectionMatrix();
if (!b || !rows || !selection) return false;
rangeClipboard = { panel: selection.panel, ...b, rows, cut: false };
pendingCut = null;
writeClipboard(toTsv(rows));
return true;
};
/** Cut the selected range: the source is cleared by the next paste (Excel). */
const cutSelection = () => {
if (!copySelection()) return false;
rangeClipboard.cut = true;
pendingCut = { panel: rangeClipboard.panel, ...selectionBounds() };
return true;
};
/** True when the user has a live text selection inside *td* (edit mode). */
const hasCellTextSelection = (td) => {
const sel = window.getSelection ? window.getSelection() : null;
return Boolean(sel && !sel.isCollapsed && sel.rangeCount && td && td.contains(sel.anchorNode));
};
/** Put the caret at the end of a cell's text (F2 / navigation → edit). */
const placeCaretAtEnd = (td) => {
const sel = window.getSelection ? window.getSelection() : null;
if (!sel || !document.createRange) return;
const range = document.createRange();
range.selectNodeContents(td);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
};
const startEditing = (td) => {
td.dataset.editing = "1";
td.focus();
placeCaretAtEnd(td);
};
/** Insert plain text at the caret inside a cell (no HTML, no execCommand). */
const insertTextAtCaret = (td, text) => {
const sel = window.getSelection ? window.getSelection() : null;
if (sel && sel.rangeCount && td.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
td.appendChild(document.createTextNode(text));
}
td.dataset.editing = "1";
td.classList.add("xlsx-dirty");
syncFormulaBar(td);
refreshSaveState();
};
/** Clear a range through the history stack (undoable), skipping *skip*. */
const clearRange = (panel, b, skip = null) => {
if (!panel || !b) return 0;
let cleared = 0;
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
if (skip && r >= skip.r1 && r <= skip.r2 && c >= skip.c1 && c <= skip.c2) continue;
const td = findTd(panel, r, c);
if (!td) continue;
const before = cellTextOf(td);
if (before === "") continue;
td.textContent = "";
td.classList.toggle("xlsx-dirty", (td.dataset.orig ?? "") !== "");
recordEdit(td, before, "");
resignPending(td);
cleared++;
}
}
refreshSaveState();
return cleared;
};
/** Split pasted text into a matrix (TSV from a spreadsheet, or plain lines). */
const parseMatrix = (text) => {
const raw = String(text ?? "").replace(/\r\n?/g, "\n");
if (!raw) return [];
const lines = raw.split("\n");
// A spreadsheet copy ends with a newline: drop the empty row it produces.
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
return lines.map((line) => line.split("\t"));
};
/** Plain-text payload of a clipboard event ("" when unavailable). */
const clipTextOf = (e) => {
const data = (e && (e.clipboardData || window.clipboardData)) || null;
if (!data || typeof data.getData !== "function") return "";
try {
return data.getData("text/plain") || data.getData("text") || "";
} catch (_) {
return "";
}
};
// A range operation that rewrites the cell being edited must not produce a
// second history entry when that cell later loses focus (commitEdit).
const resignPending = (td) => {
if (pendingEdit && pendingEdit.td === td) pendingEdit.before = td.textContent;
};
/** Write one value into a cell through the history stack. */
const setCellValue = (td, value) => {
const before = cellTextOf(td);
if (before === value) return false;
td.textContent = value;
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
recordEdit(td, before, value);
resignPending(td);
return true;
};
/** Paste a matrix starting at *startTd*; keeps the pasted block selected. */
const fillFrom = (startTd, matrix) => {
const panel = startTd && startTd.closest(".xlsx-panel");
const start = startTd && parseRef(cellName(startTd));
if (!panel || !start || !matrix.length) return null;
const rows = matrix.length;
const cols = matrix.reduce((n, line) => Math.max(n, line.length), 0);
let missing = 0;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const td = findTd(panel, start.row + i, start.col + j);
if (!td) { missing++; continue; }
setCellValue(td, matrix[i][j] ?? "");
}
}
const target = {
r1: start.row, r2: start.row + rows - 1,
c1: start.col, c2: start.col + cols - 1,
};
// Excel keeps the pasted block selected: select the whole target range
// (cells outside the rendered window are simply not painted) and tell the
// user when part of the block could not be written.
selection = {
panel,
anchor: { row: target.r1, col: target.c1 },
focus: { row: target.r2, col: target.c2 },
};
applySelection();
activeTd = startTd;
formulaInput.disabled = false;
formulaInput.value = startTd.textContent;
refreshSaveState();
if (missing) showToast(t("xlsx.paste_out_of_grid"), "info");
return target;
};
/** Paste one value: replace the cell, or insert at the caret mid-edit. */
const pasteSingleValue = (td, value) => {
if (hasCellTextSelection(td)) { insertTextAtCaret(td, value); return; }
if (setCellValue(td, value)) {
syncFormulaBar(td);
refreshSaveState();
}
};
/** Common paste path: 1×1 → single cell, otherwise fill the block. */
const applyPaste = (text, targetTd) => {
const td = targetTd || (activeTd && area.contains(activeTd) ? activeTd : null);
const matrix = parseMatrix(text);
if (!td || !matrix.length) return false;
if (matrix.length === 1 && matrix[0].length === 1) {
pasteSingleValue(td, matrix[0][0]);
} else {
const target = fillFrom(td, matrix);
// A cut is committed once its source is cleared; cells inside the target
// range are kept so cut → paste in place never erases the paste.
if (pendingCut && area.contains(pendingCut.panel)) clearRange(pendingCut.panel, pendingCut, target);
}
pendingCut = null;
if (rangeClipboard) rangeClipboard.cut = false;
return true;
};
/** Context-menu “Coller”: internal block first, system clipboard next. */
const pasteFromClipboard = async () => {
if (rangeClipboard && applyPaste(toTsv(rangeClipboard.rows))) return;
let text = "";
try {
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.readText) {
text = await navigator.clipboard.readText();
}
} catch (_) { /* permission denied / unavailable */ }
if (!text || !applyPaste(text)) showToast(t("xlsx.paste_empty"), "info");
};
// Ctrl+C / Ctrl+X are handled through the clipboard events: they also cover
// the browser's own Edit menu, and a live text selection inside the edited
// cell keeps its native behaviour.
viewerEl.addEventListener("copy", (e) => {
const td = e.target && e.target.closest ? e.target.closest("td[data-cell]") : null;
if (td && hasCellTextSelection(td)) return; // native in-cell copy
const inGrid = e.target && e.target.closest && e.target.closest(".xlsx-panel");
if (!inGrid || !copySelection()) return;
if (e.clipboardData && e.clipboardData.setData) e.clipboardData.setData("text/plain", toTsv(rangeClipboard.rows));
e.preventDefault();
});
viewerEl.addEventListener("cut", (e) => {
const td = e.target && e.target.closest ? e.target.closest("td[data-cell]") : null;
if (td && hasCellTextSelection(td)) return; // native in-cell cut
const inGrid = e.target && e.target.closest && e.target.closest(".xlsx-panel");
if (!inGrid || !cutSelection()) return;
if (e.clipboardData && e.clipboardData.setData) e.clipboardData.setData("text/plain", toTsv(rangeClipboard.rows));
e.preventDefault();
});
// ══ #156-A6 — clavier complet & #156-A7 — zone Nom ══════════════════
// A cell is in “navigation” mode until the user types (or presses F2); that
// single rule is what lets Delete clear the selection instead of deleting a
// character, and Home/End reach the row instead of the caret.
const isTypingInCell = (td) => Boolean(td && td.dataset.editing === "1");
const activeOrFirst = (panel) => {
if (panel && activeTd && panel.contains(activeTd)) return activeTd;
return panel ? panel.querySelector("td[data-cell]") : null;
};
const goTo = (td) => {
if (!td) return;
setActiveCell(td);
td.focus();
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
};
/** Move the `xlsx-active` marker without touching the range selection. */
const markActive = (td) => {
const prev = area.querySelector("td.xlsx-active");
if (prev && prev !== td) prev.classList.remove("xlsx-active");
if (td) td.classList.add("xlsx-active");
};
const gridGeom = (panel) => ({
rows: panel.querySelectorAll("th.xlsx-rownum").length,
cols: panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length,
});
/** Ctrl+flèche — last cell of a data run (or the first non-empty after a gap). */
const jumpActive = (panel, key, extend) => {
const start = activeOrFirst(panel);
const ref = start && parseRef(cellName(start));
const step = {
ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0],
}[key];
if (!ref || !step) return;
const [dr, dc] = step;
const at = (i) => findTd(panel, ref.row + dr * i, ref.col + dc * i);
const blank = (td) => !td || cellTextOf(td).trim() === "";
let last = 0;
while (at(last + 1)) last++;
if (!last) return;
let i = 1;
if (blank(at(1))) {
while (i < last && blank(at(i))) i++; // skip the gap → next data
} else {
while (i < last && !blank(at(i + 1))) i++; // end of the data run
}
const target = at(i);
if (!target || target === start) return;
if (extend && selection && selection.panel === panel) {
suppressSelectionReset = true;
target.focus();
suppressSelectionReset = false;
selection.focus = parseRef(cellName(target)) || selection.focus;
applySelection();
activeTd = target;
formulaInput.value = target.textContent;
return;
}
goTo(target);
};
const selectAllCells = (panel) => {
const { rows, cols } = gridGeom(panel);
if (!rows || !cols) return;
selection = { panel, anchor: { row: 1, col: 1 }, focus: { row: rows, col: cols } };
applySelection();
};
const edgeOfRow = (panel, which) => {
const cur = activeOrFirst(panel);
const ref = cur && parseRef(cellName(cur));
const { cols } = gridGeom(panel);
if (!ref || !cols) return;
goTo(findTd(panel, ref.row, which === "first" ? 1 : cols));
};
const sheetCorner = (panel, key) => {
if (key === "Home") { goTo(panel.querySelector("td[data-cell]")); return; }
const { rows, cols } = gridGeom(panel);
if (rows && cols) goTo(findTd(panel, rows, cols));
};
const pageMove = (panel, dir) => {
const cur = activeOrFirst(panel);
const ref = cur && parseRef(cellName(cur));
const { rows } = gridGeom(panel);
if (!ref || !rows) return;
// One screenful: visible height ÷ row height, with a fallback when the
// layout is not measured (JSDOM reports 0 for every box).
const wrapper = panel.querySelector(".csv-table-wrapper");
const rowEl = panel.querySelector("tbody tr");
const h = (wrapper && wrapper.clientHeight) || 0;
const rh = (rowEl && rowEl.getBoundingClientRect().height) || 0;
const step = h && rh ? Math.max(1, Math.floor(h / rh) - 1) : 10;
const row = Math.min(rows, Math.max(1, ref.row + dir * step));
goTo(findTd(panel, row, ref.col));
};
/** #156-A7 — jump to a typed reference ("B12", "A1:B3", "Feuille!B12"). */
const goToRef = (raw) => {
const value = String(raw || "").trim().replace(/\$/g, "");
if (!value) return false;
const sheetName = sheetOfRef(value);
const body = value.includes("!") ? value.split("!").pop() : value;
const parts = body.toUpperCase().split(":");
const from = parseRef(parts[0]);
const to = parts[1] ? parseRef(parts[1]) : from;
if (!from || !to) return false;
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) return false;
tab.click();
panel = panelEls[idx];
}
if (!panel) return false;
const anchorTd = findTd(panel, from.row, from.col);
if (!anchorTd) { showToast(t("xlsx.range_not_visible"), "info"); return false; }
const focusTd = findTd(panel, to.row, to.col) || anchorTd;
selection = {
panel,
anchor: { row: from.row, col: from.col },
focus: parseRef(cellName(focusTd)) || from,
};
activeTd = anchorTd;
markActive(anchorTd);
formulaInput.disabled = false;
formulaInput.value = anchorTd.textContent;
// Focusing the anchor must not collapse a typed range back to one cell:
// the focus handler skips the selection reset under this flag.
suppressSelectionReset = true;
anchorTd.focus();
suppressSelectionReset = false;
selection = {
panel,
anchor: { row: from.row, col: from.col },
focus: parseRef(cellName(focusTd)) || from,
};
applySelection();
if (typeof anchorTd.scrollIntoView === "function") anchorTd.scrollIntoView({ block: "center" });
return true;
};
activeCellEl.addEventListener("focus", () => { activeCellEl.select(); });
activeCellEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const okRef = goToRef(activeCellEl.value);
if (!okRef) showToast(t("xlsx.name_box_invalid", { ref: activeCellEl.value }), "error");
activeCellEl.value = rangeLabel() || "";
if (activeTd) activeTd.focus();
} else if (e.key === "Escape") {
e.preventDefault();
activeCellEl.value = rangeLabel() || "";
if (activeTd) activeTd.focus();
}
});
// #156-A7 — function-name suggestions for the formula bar. The names are
// localized through the catalog (Excel/Sheets each ship their own spelling).
const fnList = area.querySelector("#xlsx-function-list");
if (fnList) {
String(t("xlsx.fn_suggestions") || "")
.split(";")
.map((s) => s.trim())
.filter(Boolean)
.forEach((name) => {
const opt = document.createElement("option");
opt.value = name;
fnList.appendChild(opt);
});
}
// #156-A6 — one grid-level keyboard handler, in priority order: save is
// global (the formula bar keeps it too), then undo/redo, then navigation.
// The formula/find inputs keep their native editing keys.
area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => {
const key = e.key;
const lower = key.length === 1 ? key.toLowerCase() : key;
const mod = e.ctrlKey || e.metaKey;
const inBar = e.target === formulaInput || e.target === activeCellEl;
const panel = (selection && selection.panel) || (e.target.closest && e.target.closest(".xlsx-panel")) || visiblePanel();
if (!panel) return;
if (mod && lower === "s") {
e.preventDefault();
if (!saveBtn.disabled) saveBtn.click();
return;
}
if (inBar) return; // formula/find inputs: native editing keys
if (mod && lower === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
if (mod && lower === "y") { e.preventDefault(); redo(); return; }
if (readOnly) return; // navigation below would activate an uneditable grid
if (mod && lower === "a") { e.preventDefault(); selectAllCells(panel); return; }
if (mod && key.startsWith("Arrow")) { e.preventDefault(); jumpActive(panel, key, e.shiftKey); return; }
if (mod && (key === "Home" || key === "End")) { e.preventDefault(); sheetCorner(panel, key); return; }
if (key === "Delete" && !isTypingInCell(activeTd)) {
const b = selectionBounds();
if (!selection || !b) return;
e.preventDefault();
clearRange(selection.panel, b);
return;
}
if (!mod && (key === "Home" || key === "End") && !isTypingInCell(activeTd)) {
e.preventDefault();
edgeOfRow(panel, key === "Home" ? "first" : "last");
return;
}
if (!mod && (key === "PageUp" || key === "PageDown")) {
e.preventDefault();
pageMove(panel, key === "PageDown" ? 1 : -1);
}
});
const contextTargetFrom = (target) => {
if (!target || !target.closest) return null;
const panel = target.closest(".xlsx-panel");
if (!panel) return null;
const rownum = target.closest("th.xlsx-rownum");
if (rownum) return { panel, kind: "row", row: Number(rownum.textContent) };
const header = target.closest(".xlsx-table thead th:not(.xlsx-corner)");
if (header) return { panel, kind: "col", col: [...header.parentElement.children].indexOf(header) };
const td = target.closest("td[data-cell]");
if (td) return { panel, kind: "cell", td };
return null;
};
const selectForTarget = (target) => {
if (target.kind === "row") selectRow(target.panel, target.row);
else if (target.kind === "col") selectColumn(target.panel, target.col);
else if (target.kind === "cell"
&& (!selection || selection.panel !== target.panel || !inSelection(target.td))) {
setActiveCell(target.td);
}
};
// One structure write (insert/delete) with the usual lossy/error handling;
// destructive actions ask for confirmation first.
const structureAction = (action, confirmMsg) => {
const run = async () => {
try { await putStructure([action]); } catch (err) { structureError(err, [action]); }
};
if (confirmMsg) {
showConfirm({
title: t("xlsx.structure_title"),
message: confirmMsg,
confirmLabel: t("common.confirm"),
danger: true,
}).then((ok) => { if (ok) run(); });
} else {
run();
}
};
// #156-A5/A6 — clearing goes through clearRange() so "Effacer le contenu",
// Suppr and the cut source all share the same undoable path.
const clearContents = (panel, b) => clearRange(panel, b);
const buildContextItems = (target) => {
const panel = target.panel;
const sheetName = sheets[Number(panel.dataset.sheet)]?.name || "";
const b = selectionBounds();
if (!b) return [];
const rowCount = b.r2 - b.r1 + 1;
const colCount = b.c2 - b.c1 + 1;
const items = [];
// #156-A5 — clipboard entries first, in the spreadsheet order.
items.push({ label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
items.push({ label: t("xlsx.copy"), onClick: () => copySelection() });
items.push({ label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
items.push({ separator: true });
if (editable) {
items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ separator: true });
items.push({ label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ separator: true });
}
if (!isCsv) {
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
}
if (editable) {
items.push({ separator: true });
items.push({ label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
}
return items;
};
const openGridMenu = (x, y, target) => {
selectForTarget(target);
openContextMenu(x, y, buildContextItems(target));
};
viewerEl.addEventListener("contextmenu", (e) => {
const target = contextTargetFrom(e.target);
if (!target) return;
e.preventDefault();
openGridMenu(e.clientX, e.clientY, target);
});
// Long-press (touch) — the mobile equivalent of a right-click.
let longPressTimer = null;
const cancelLongPress = () => {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
};
viewerEl.addEventListener("touchstart", (e) => {
const target = contextTargetFrom(e.target);
if (!target || !e.touches || !e.touches[0]) return;
const t0 = e.touches[0];
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
openGridMenu(t0.clientX, t0.clientY, target);
}, 500);
}, { passive: true });
viewerEl.addEventListener("touchmove", cancelLongPress, { passive: true });
viewerEl.addEventListener("touchend", cancelLongPress);
viewerEl.addEventListener("touchcancel", cancelLongPress);
// Drag-to-select a rectangular range + Shift+click extension.
let dragAnchorTd = null;
let dragActive = false;
let dragExtended = false;
viewerEl.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
const td = e.target.closest && e.target.closest("td[data-cell]");
if (!td) return;
if (e.shiftKey && selection && selection.panel === td.closest(".xlsx-panel")) {
e.preventDefault();
selection.focus = parseRef(cellName(td)) || selection.focus;
applySelection();
activeTd = td;
formulaInput.disabled = false;
formulaInput.value = td.textContent;
return;
}
dragAnchorTd = td;
dragActive = true;
dragExtended = false;
});
viewerEl.addEventListener("mouseover", (e) => {
if (!dragActive || !dragAnchorTd) return;
const td = e.target.closest && e.target.closest("td[data-cell]");
if (!td || td === dragAnchorTd) return;
const panel = dragAnchorTd.closest(".xlsx-panel");
if (td.closest(".xlsx-panel") !== panel) return;
const a = parseRef(cellName(dragAnchorTd));
const f = parseRef(cellName(td));
if (!a || !f) return;
if (!dragExtended) {
dragExtended = true;
// Stop editing the anchor cell and drop any in-cell text selection.
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
if (document.getSelection) { const s = document.getSelection(); if (s && s.removeAllRanges) s.removeAllRanges(); }
viewerEl.classList.add("xlsx-selecting");
}
selection = { panel, anchor: a, focus: f };
applySelection();
});
const endDrag = () => {
if (dragActive && dragExtended && dragAnchorTd) {
activeTd = dragAnchorTd;
formulaInput.disabled = false;
formulaInput.value = dragAnchorTd.textContent;
}
dragActive = false;
dragExtended = false;
dragAnchorTd = null;
viewerEl.classList.remove("xlsx-selecting");
};
viewerEl.addEventListener("mouseup", endDrag);
viewerEl.addEventListener("mouseleave", endDrag);
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 = '<div class="pdf-toc"><h3>Table des matières</h3><ul>';
for (const item of toc) {
const indent = (item.level - 1) * 16;
tocHtml += `<li style="padding-left:${indent}px"><a href="#" data-page="${item.page}">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`;
}
tocHtml += '</ul></div>';
}
area.innerHTML = `
<div class="pdf-viewer-container">
<div class="pdf-toolbar">
<span class="pdf-info">PDF — ${pages} pages</span>
<button class="btn-action" data-pdf-url="${pdfUrl}">
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
</button>
<button class="btn-action" data-download-url="/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>
<div class="pdf-body">
${tocHtml}
<iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
</div>
</div>`;
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 = `
<div class="unsupported-file">
<i data-lucide="file" style="width:48px;height:48px"></i>
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
<button class="btn-action" id="unsupported-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>`;
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)));
}
}
// #158 — the static « Raccourcis & Astuces » block lives in index.html inside
// #dashboard-panel-stats; showWelcome() rebuilds #dashboard-home from a
// template that omits it, so the block disappeared after the first rebuild.
// Capture it once, at module load (before any rebuild can destroy it).
const QUICK_HELP_HTML = (() => {
if (typeof document === "undefined") return "";
const qh = document.getElementById("quick-help");
return qh ? qh.outerHTML : "";
})();
export function showWelcome() {
hideProgressBar();
// #158 — the dashboard takes over the content area: drop the active-tab
// marker so clicking a tab re-renders it instead of early-returning.
if (window.TabManager && window.TabManager.deactivate) window.TabManager.deactivate();
// 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 = `
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
<!-- Dashboard Tabs -->
<div class="dashboard-tabs">
<button class="dashboard-tab active" data-tab="stats" data-i18n="dashboard.stats">
<i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> ${t('dashboard.stats')}
</button>
<button class="dashboard-tab" data-tab="bookmarks" data-i18n="dashboard.bookmarks">
<i data-lucide="bookmark" style="width:14px;height:14px"></i> ${t('dashboard.bookmarks')}
</button>
<button class="dashboard-tab" data-tab="recent" data-i18n="dashboard.tab_recent">
<i data-lucide="clock" style="width:14px;height:14px"></i> ${t('dashboard.tab_recent')}
</button>
<button class="dashboard-tab" data-tab="shared" data-i18n="dashboard.tab_shared">
<i data-lucide="share-2" style="width:14px;height:14px"></i> ${t('dashboard.tab_shared')}
</button>
</div>
<!-- Stats Panel -->
<div id="dashboard-panel-stats" class="dashboard-panel active">
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
<div class="dashboard-stats-loading" data-i18n="common.loading">${t('common.loading')}</div>
</div>
<div id="dashboard-conflicts-container" style="margin-top:16px"></div>
${QUICK_HELP_HTML}
</div>
<!-- Bookmarks Panel -->
<div id="dashboard-panel-bookmarks" class="dashboard-panel">
<div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
<div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
<i data-lucide="pin"></i>
<span data-i18n="dashboard.no_bookmarks">${t('dashboard.no_bookmarks')}</span>
<p data-i18n="dashboard.pin_hint">${t('dashboard.pin_hint')}</p>
</div>
</div>
<!-- Recent Panel -->
<div id="dashboard-panel-recent" class="dashboard-panel">
<div class="dashboard-header">
<div class="dashboard-title-row">
<span id="dashboard-count" class="dashboard-badge"></span>
</div>
</div>
<div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
<div id="dashboard-loading" class="dashboard-loading">
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
</div>
<div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
<i data-lucide="inbox"></i>
<span data-i18n="dashboard.no_recent">${t('dashboard.no_recent')}</span>
<p data-i18n="dashboard.recent_hint">${t('dashboard.recent_hint')}</p>
</div>
</div>
<!-- Shared Panel -->
<div id="dashboard-panel-shared" class="dashboard-panel">
<div id="dashboard-shared-grid" class="dashboard-recent-grid"></div>
<div id="dashboard-shared-empty" class="dashboard-recent-empty">
<i data-lucide="share-2"></i>
<span data-i18n="dashboard.no_shares">${t('dashboard.no_shares')}</span>
<p data-i18n="dashboard.shared_hint">${t('dashboard.shared_hint')}</p>
</div>
</div>
</div>`;
// 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
? '<i data-lucide="folder-search" style="width:14px;height:14px;color:var(--accent)" class="saved-search-type-icon"></i>'
: '<i data-lucide="search" style="width:14px;height:14px;color:var(--text-muted)" class="saved-search-type-icon"></i>';
const badges = [];
if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
const pathFilters = [];
if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">📥 ${escapeHtml(s.include_paths)}</span>`);
if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">📤 ${escapeHtml(s.exclude_paths)}</span>`);
const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">📁 ${escapeHtml(s.vault)}</span>` : "";
return `
<div class="saved-search-item${isDirSearch ? ' saved-search-dir' : ''}" data-type="${isDirSearch ? 'directory' : 'search'}">
<div class="saved-search-query">${displayQuery}</div>
<div class="saved-search-meta">
<span class="saved-search-badges">${badges.join("")} ${vaultStr}</span>
${typeIcon}
</div>
${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
</div>
`}).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 = `
<div class="loading-indicator">
<div class="loading-spinner"></div>
<div>Recherche en cours...</div>
</div>`;
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 = `<div class="sync-panel__header">
<span class="sync-panel__title">Synchronisation</span>
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
</div>`;
if (events.length === 0) {
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
} else {
html += `<div class="sync-panel__events">`;
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 += `<div class="sync-panel__event">
<span class="sync-panel__event-type">${label}</span>
<span class="sync-panel__event-detail">${detail}</span>
<span class="sync-panel__event-time">${time}</span>
</div>`;
});
html += `</div>`;
}
}
// ── 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);
}
}