/* 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, getLocale } from './i18n.js'; import { onFileRender } from './plugins.js'; import { NowPlaying } from './now-playing.js'; import { openAssistant } from './ai-fab.js'; import { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js'; import { createColourPicker } from './xlsx/color-picker.js'; import { buildCommandBar } from './xlsx/command-bar.js'; import { buildMenuBar } from './xlsx/menus.js'; import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js'; import { openContextMenu, closeContextMenu, trackDismissable, enableArrowNav } from './xlsx/context-menu.js'; import { isFormulaText, parseFormula, evaluate, createScope, displayValue, colToIndex, indexToCol, } from './xlsx/formula.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"; }); }); // ── Spreadsheet header-edge resize: pointer routing (module level) ───────── // One drag at a time; the context (area, panel, commit) is captured by the // render that starts it, so re-renders never leave stale closures behind. let activeResizeDrag = null; if (typeof document !== "undefined" && !document.__xlsxResizeRouted) { document.__xlsxResizeRouted = true; const resizeTrackMove = (clientX, clientY) => { const d = activeResizeDrag; if (!d || typeof d.move !== "function") return; d.move(clientX, clientY); }; document.addEventListener("mousemove", (e) => resizeTrackMove(e.clientX, e.clientY)); document.addEventListener("mouseup", () => { if (activeResizeDrag && typeof activeResizeDrag.commit === "function") { const d = activeResizeDrag; activeResizeDrag = null; d.commit(); } }); document.addEventListener("touchmove", (e) => { if (!activeResizeDrag || !e.touches || !e.touches[0]) return; e.preventDefault(); resizeTrackMove(e.touches[0].clientX, e.touches[0].clientY); }, { passive: false }); document.addEventListener("touchend", () => { if (activeResizeDrag && typeof activeResizeDrag.commit === "function") { const d = activeResizeDrag; activeResizeDrag = null; d.commit(); } }); document.addEventListener("touchcancel", () => { if (activeResizeDrag && typeof activeResizeDrag.cancel === "function") { const d = activeResizeDrag; activeResizeDrag = null; d.cancel(); } }); // A drag that moved must not toggle the header selection on release. document.addEventListener("click", (e) => { const d = activeResizeDrag; if (!d || !d.suppressClick) return; const t = e.target && e.target.closest ? e.target.closest("thead th, th.xlsx-rownum") : null; if (t) { e.stopPropagation(); e.preventDefault(); } d.suppressClick = false; }, true); } // Hide the floating mini-bar when it loses its context (module level: pure DOM). if (typeof document !== "undefined" && typeof window !== "undefined" && !document.__xlsxMinibarRouted) { document.__xlsxMinibarRouted = true; document.addEventListener("keydown", (e) => { if (e.key === "Escape") { document.querySelectorAll(".xlsx-minibar").forEach((m) => { m.hidden = true; }); } }); window.addEventListener("scroll", () => { document.querySelectorAll(".xlsx-minibar").forEach((m) => { m.hidden = true; }); }, true); } // Pane-aware content area helper — delegates to PaneManager when split function getContentArea() { // Override set by PaneTabManager._renderFileInPane for cross-module rendering if (window._activePaneContentArea) return window._activePaneContentArea; if (window.PaneManager && window.PaneManager.isSplit() && window.getActiveContentArea) { return window.getActiveContentArea(); } return document.getElementById('content-area'); } // --------------------------------------------------------------------------- // Outline/TOC Manager // --------------------------------------------------------------------------- const OutlineManager = { /** * Slugify text to create valid IDs */ slugify(text) { return ( text .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .trim() || "heading" ); }, /** * Parse headings from markdown content */ parseHeadings() { const contentArea = document.querySelector(".md-content"); if (!contentArea) return []; const headings = []; const h2s = contentArea.querySelectorAll("h2"); const h3s = contentArea.querySelectorAll("h3"); const allHeadings = [...h2s, ...h3s].sort((a, b) => { return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); const usedIds = new Map(); allHeadings.forEach((heading) => { const text = heading.textContent.trim(); if (!text) return; const level = parseInt(heading.tagName[1]); let id = this.slugify(text); // Handle duplicate IDs if (usedIds.has(id)) { const count = usedIds.get(id) + 1; usedIds.set(id, count); id = `${id}-${count}`; } else { usedIds.set(id, 1); } // Inject ID into heading if not present if (!heading.id) { heading.id = id; } else { id = heading.id; } headings.push({ id, level, text, element: heading, }); }); return headings; }, /** * Render outline list */ renderOutline(headings) { const outlineList = document.getElementById("outline-list"); const outlineEmpty = document.getElementById("outline-empty"); if (!outlineList) return; outlineList.innerHTML = ""; if (!headings || headings.length === 0) { outlineList.hidden = true; if (outlineEmpty) { outlineEmpty.hidden = false; safeCreateIcons(); } return; } outlineList.hidden = false; if (outlineEmpty) outlineEmpty.hidden = true; headings.forEach((heading) => { const item = el( "a", { class: `outline-item level-${heading.level}`, href: `#${heading.id}`, "data-heading-id": heading.id, role: "link", }, [document.createTextNode(heading.text)], ); item.addEventListener("click", (e) => { e.preventDefault(); this.scrollToHeading(heading.id); }); outlineList.appendChild(item); }); state.headingsCache = headings; }, /** * Scroll to heading with smooth behavior */ scrollToHeading(headingId) { const heading = document.getElementById(headingId); if (!heading) return; const contentArea = getContentArea(); if (!contentArea) return; // Calculate offset for fixed header (if any) const headerHeight = 80; const headingTop = heading.offsetTop; contentArea.scrollTo({ top: headingTop - headerHeight, behavior: "smooth", }); // Update active state immediately this.setActiveHeading(headingId); }, /** * Set active heading in outline */ setActiveHeading(headingId) { if (state.activeHeadingId === headingId) return; state.activeHeadingId = headingId; const items = document.querySelectorAll(".outline-item"); items.forEach((item) => { if (item.getAttribute("data-heading-id") === headingId) { item.classList.add("active"); item.setAttribute("aria-current", "location"); // Scroll outline item into view item.scrollIntoView({ block: "nearest", behavior: "smooth" }); } else { item.classList.remove("active"); item.removeAttribute("aria-current"); } }); }, /** * Initialize outline for current document */ init() { const headings = this.parseHeadings(); this.renderOutline(headings); ScrollSpyManager.init(headings); ReadingProgressManager.init(); }, /** * Cleanup */ destroy() { ScrollSpyManager.destroy(); ReadingProgressManager.destroy(); state.headingsCache = []; state.activeHeadingId = null; }, }; // --------------------------------------------------------------------------- // Scroll Spy Manager // --------------------------------------------------------------------------- const ScrollSpyManager = { observer: null, headings: [], init(headings) { this.destroy(); this.headings = headings; if (!headings || headings.length === 0) return; const contentArea = getContentArea(); if (!contentArea) return; const options = { root: contentArea, rootMargin: "-20% 0px -70% 0px", threshold: [0, 0.3, 0.5, 1.0], }; this.observer = new IntersectionObserver((entries) => { // Find the most visible heading let mostVisible = null; let maxRatio = 0; entries.forEach((entry) => { if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { maxRatio = entry.intersectionRatio; mostVisible = entry.target; } }); if (mostVisible && mostVisible.id) { OutlineManager.setActiveHeading(mostVisible.id); } }, options); // Observe all headings headings.forEach((heading) => { if (heading.element) { this.observer.observe(heading.element); } }); }, destroy() { if (this.observer) { this.observer.disconnect(); this.observer = null; } this.headings = []; }, }; // --------------------------------------------------------------------------- // Reading Progress Manager // --------------------------------------------------------------------------- const ReadingProgressManager = { scrollHandler: null, init() { this.destroy(); const contentArea = getContentArea(); if (!contentArea) return; this.scrollHandler = this.throttle(() => { this.updateProgress(); }, 100); contentArea.addEventListener("scroll", this.scrollHandler); this.updateProgress(); }, updateProgress() { const contentArea = getContentArea(); const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); if (!contentArea || !progressFill || !progressText) return; const scrollTop = contentArea.scrollTop; const scrollHeight = contentArea.scrollHeight; const clientHeight = contentArea.clientHeight; const maxScroll = scrollHeight - clientHeight; const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 0; progressFill.style.width = `${percentage}%`; progressText.textContent = `${percentage}%`; }, throttle(func, delay) { let lastCall = 0; return function (...args) { const now = Date.now(); if (now - lastCall >= delay) { lastCall = now; func.apply(this, args); } }; }, destroy() { const contentArea = getContentArea(); if (contentArea && this.scrollHandler) { contentArea.removeEventListener("scroll", this.scrollHandler); } this.scrollHandler = null; // Reset progress const progressFill = document.getElementById("reading-progress-fill"); const progressText = document.getElementById("reading-progress-text"); if (progressFill) progressFill.style.width = "0%"; if (progressText) progressText.textContent = "0%"; }, }; // --------------------------------------------------------------------------- // File viewer // --------------------------------------------------------------------------- export async function openFile(vaultName, filePath) { state.currentVault = vaultName; state.currentPath = filePath; state.showingSource = false; state.cachedRawSource = null; // Highlight active syncActiveFileTreeItem(vaultName, filePath); // Show loading state while fetching const area = getContentArea(); area.innerHTML = '
Chargement...
'; try { const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`; const data = await api(url); // Cache the file for offline use if (data && !data.unsupported && data.content) { cacheViewedFile(vaultName, filePath, data.content, data.modified); } renderFile(data); } catch (err) { // Try IndexedDB fallback console.log('[Viewer] Network failed, trying offline cache:', err); const cached = await getCachedFile(vaultName, filePath); if (cached && cached.content) { const fakeData = { vault: vaultName, path: filePath, title: filePath.split('/').pop().replace(/\.md$/, ''), content: cached.content, html: null, raw_size: cached.content.length, modified: cached.modified || '', tags: [], offline: true }; renderFile(fakeData); showToast(t('viewer.loaded_from_cache'), 'info'); } else { area.innerHTML = '

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

'; } } } async function renderBacklinksPanel(vault, path, container) { try { const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`); if (!data.backlinks || data.backlinks.length === 0) return; const panel = el("div", { class: "backlinks-panel" }); const header = el("div", { class: "backlinks-header" }, [ icon("link", 14), document.createTextNode(` ${data.total} lien(s) entrant(s)`), ]); panel.appendChild(header); const list = el("div", { class: "backlinks-list" }); data.backlinks.forEach((bl) => { const item = el("div", { class: "backlink-item" }); const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]); const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]); item.appendChild(icon(getFileIcon(bl.path), 12)); item.appendChild(vaultBadge); item.appendChild(titleEl); item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path)); item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); }); list.appendChild(item); }); panel.appendChild(list); container.appendChild(panel); } catch (err) { // Silently ignore — backlinks are optional console.debug("Backlinks fetch failed:", err); } } // ── Pretty print (mode lecture enrichi pour fichiers texte non-markdown) ── // Detecte le langage depuis le HTML deja rendu par le backend (classe // language-xxx), formate le JSON, puis genere un bloc avec gouttiere de // numeros de ligne. La coloration est appliquee ensuite via highlight.js. function detectPrettyLang(data) { if (data.is_json || data.extension === ".json") return "json"; const m = /class="language-([\w-]+)/.exec(data.html || ""); return m ? m[1] : "plaintext"; } function buildPrettyHtml(data, raw) { let text = raw; const lang = detectPrettyLang(data); if (lang === "json") { try { text = JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { /* JSON invalide: on affiche brut */ } } const lines = text.split("\n"); const gutter = lines.map((_, i) => `${i + 1}`).join(""); return `
${escapeHtml(text)}
`; } // Colorateur générique de secours: utilisé quand highlight.js ne produit // aucun token (langage plaintext — .txt/.log —, langue absente du bundle, // ou CDN hljs indisponible). Réutilise les classes hljs existantes (le // thème CDN colore string/comment/number/attr) + classes .pp-* maison // pour les niveaux de log qui n'ont pas d'équivalent sémantique hljs. const PP_TOKEN_RE = /("[^"\n]*"?|'[^'\n]*'?|`[^`\n]*`?)|(^\s*(?:#|\/\/|;)\s.*$|\s+(?:#|\/\/)\s.*$)|\b(\d+(?:\.\d+)?)\b|\b(ERROR|FATAL|CRITICAL|SEVERE|WARN(?:ING)?|INFO|DEBUG|TRACE|NOTICE)\b|^(\s*[\w.\-\/ ]+)=(?=\S)|\b(true|false|null|None|TRUE|FALSE|NULL)\b/gm; function ppClassFor(m) { if (m[1] !== undefined) return "hljs-string"; if (m[2] !== undefined) return "hljs-comment"; if (m[3] !== undefined) return "hljs-number"; if (m[4] !== undefined) { const lvl = m[4]; if (/ERROR|FATAL|CRITICAL|SEVERE/.test(lvl)) return "pp-level pp-err"; if (/WARN/.test(lvl)) return "pp-level pp-warn"; if (/INFO|NOTICE/.test(lvl)) return "pp-level pp-info"; return "pp-level pp-debug"; } if (m[5] !== undefined) return "hljs-attr"; if (m[6] !== undefined) return "hljs-literal"; return null; } function fallbackTokenize(text) { let out = ""; let last = 0; PP_TOKEN_RE.lastIndex = 0; let m; while ((m = PP_TOKEN_RE.exec(text)) !== null) { if (m.index > last) out += escapeHtml(text.slice(last, m.index)); const cls = ppClassFor(m); out += cls ? `${escapeHtml(m[0])}` : escapeHtml(m[0]); last = m.index + (m[0].length || 1); if (m[0].length === 0) PP_TOKEN_RE.lastIndex++; // garde anti-boucle infinie } out += escapeHtml(text.slice(last)); return out; } // Applique la coloration: hljs (avec aliases de languages) si des tokens // sortent, sinon fallback regex generique. function applyPrettyHighlight(codeEl, lang, text) { safeHighlight(codeEl); // gerne les alias language-xxx inconnus de hljs const tokens = codeEl.querySelectorAll('[class^="hljs-"]').length; if (!tokens && text.trim()) { codeEl.innerHTML = fallbackTokenize(text); codeEl.classList.add("hljs"); } } /** * Jump the inline PDF viewer to a specific page. * * The browser's built-in PDF viewer lives in an ``about:blank`` content window * (so ``contentWindow.location.hash`` never reaches the document) **and** it * ignores a same-document fragment navigation: changing only ``#page=N`` on the * iframe ``src`` does not move the page. A changing query parameter forces a * real reload, and the ``#page=N`` fragment is then honoured at load — the only * reliable way to target a page with the native viewer. * * @param {HTMLElement} area - Content area containing the ``.pdf-iframe``. * @param {string|number} page - 1-based page number from the PDF outline. */ export function navigatePdfToPage(area, page) { const iframe = area && area.querySelector('.pdf-iframe'); if (!iframe || page === null || page === undefined || page === '') return; const base = iframe.getAttribute('data-pdf-url') || iframe.src.split('#')[0]; iframe.setAttribute('data-pdf-url', base); const sep = base.includes('?') ? '&' : '?'; iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`; } // --------------------------------------------------------------------------- // Image viewer (roadmap #108-D) // --------------------------------------------------------------------------- const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]); const IMAGE_ZOOM_MIN = 0.1; const IMAGE_ZOOM_MAX = 8; let _imageViewerCleanup = null; // BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file // is rendered. #111 — image→image navigation now happens **in place**, so the // viewer (and these states) is no longer destroyed between siblings. const _imageViewerState = { lightbox: false, meta: false }; /** Clamp a zoom factor into the supported [0.1, 8] range. */ export function clampImageZoom(value) { if (!Number.isFinite(value)) return 1; return Math.min(IMAGE_ZOOM_MAX, Math.max(IMAGE_ZOOM_MIN, value)); } /** True when *p* has a viewable image extension. */ export function isImagePath(p) { const lower = (p || "").toLowerCase(); const dot = lower.lastIndexOf("."); return dot !== -1 && IMAGE_EXTS.has(lower.slice(dot)); } /** Build the byte-serving URL used by / thumbnails. */ export function buildImageUrl(vault, path) { return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`; } function buildThumbUrl(vault, path, size) { return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`; } // #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 `
${escapeHtml(t("xlsx.truncated_title"))} ${escapeHtml(reasons.join(" "))} ${escapeHtml(t("xlsx.truncated_hint"))}
`; } export function renderXlsxViewer(area, data) { // #184 — `area` outlives a re-render, so every listener bound to it must be // dropped first. The formula-link click handler below closes over the whole // grid (panelEls, activeTd, selection): without this teardown each re-render // kept the previous grid alive — opening or saving a spreadsheet a few times // grew the tab's heap without bound. if (typeof area._xlsxDropFormulaLinkClick === "function") { try { area._xlsxDropFormulaLinkClick(); } catch (_) { /* ignore */ } area._xlsxDropFormulaLinkClick = null; } 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, fileHtml } = buildCommandBar({ sheets, isCsv, readOnly, editable }); // #185 — the Google Sheets menu bar (Fichier · Modifier · Affichage · // Insertion · Format · Données · Outils · Gemini · Extensions · Aide) sits // ABOVE the ribbon, and every entry's action is resolved further down by // `menuActions()`. Built here so it is a pure string like the ribbon. const menuBarHtml = buildMenuBar({ editable, readOnly }); // #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). /** Comment marker of one cell (empty span: text extraction unaffected). */ const decorateComments = (td) => { const panel = td.closest ? td.closest(".xlsx-panel") : null; const meta = sheets[Number(panel && panel.dataset ? panel.dataset.sheet : NaN)] || {}; const text = (meta.comments || {})[td.dataset.cell]; const has = td.querySelector ? td.querySelector(".xlsx-comment-dot") : null; if (text && !has) { const dot = document.createElement("span"); dot.className = "xlsx-comment-dot"; dot.title = text; td.appendChild(dot); } else if (!text && has) { has.remove(); } }; const applySheetMeta = (panel) => { const meta = sheets[Number(panel.dataset.sheet)] || {}; const styleMap = meta.styles || {}; const alignMap = meta.aligns || {}; const comments = meta.comments || {}; // #184 — the padded grid holds ~26 000 cells; only the ones the workbook // actually styles are worth touching (the clones/queries below are not). panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => { const ref = td.dataset.cell; const style = styleMap[ref]; const align = alignMap[ref]; if (!style && !align && !comments[ref]) return; if (style) td.setAttribute("style", style); if (align) td.style.textAlign = align; decorateComments(td); }); // Explicit column widths / row heights (col_width & row_height ops): // a colgroup carries the widths (units → px), rows take their height. const table = panel.querySelector(".xlsx-table"); if (table) { const widths = meta.colwidths || {}; let cg = table.querySelector("colgroup.xlsx-cols"); if (!cg) { cg = document.createElement("colgroup"); cg.className = "xlsx-cols"; table.prepend(cg); } cg.replaceChildren(); const heads = table.querySelectorAll("thead th"); heads.forEach((th, i) => { const col = document.createElement("col"); if (i === 0) { col.style.width = "44px"; // row-number gutter (matches frozen offset) } else { const w = widths[columnName(i)]; if (w) col.style.width = `${Math.round(Number(w) * 7)}px`; } cg.appendChild(col); }); const heights = meta.rowheights || {}; table.querySelectorAll("tbody tr").forEach((tr) => { const h = heights[String(rowNumOf(tr))]; if (h) tr.style.height = `${Math.round(Number(h) * 96 / 72)}px`; else tr.style.height = ""; }); } // 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). // Reset first: dynamic updates (unmerge, structure surgery) must also be // able to REMOVE spans and reveals. Filtered rows hide TRs, never TDs, so // clearing td-level display is safe. panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => { td.rowSpan = 1; td.colSpan = 1; if (td.style.display === "none") td.style.display = ""; }); (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. // Reset first so unfreezing clears the classes without a re-render. panel.querySelectorAll(".xlsx-frozen-row").forEach((tr) => tr.classList.remove("xlsx-frozen-row")); panel.querySelectorAll(".xlsx-frozen-col").forEach((td) => td.classList.remove("xlsx-frozen-col")); const freeze = meta.freeze; if (freeze && freeze !== "A1") { const f = parseRef(freeze); if (f) { panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { const ref = tr.querySelector("td")?.dataset.cell || ""; const p = parseRef(ref); if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row"); }); panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => { const p = parseRef(td.dataset.cell); if (p && p.col < f.col) td.classList.add("xlsx-frozen-col"); }); } } }; // #154-A4 — ARIA grid semantics (the backend renders a plain ). const applyGridA11y = (panel) => { const table = panel.querySelector(".xlsx-table"); if (!table) return; table.setAttribute("role", "grid"); table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader")); table.querySelectorAll("tbody tr").forEach((tr) => { tr.setAttribute("role", "row"); tr.querySelectorAll("th, td").forEach((cell) => { cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell"); }); }); }; // #184 — every sheet is laid out like a real spreadsheet: the grid always // shows columns A → Z and 1000 rows, and the empty cells are KEPT (they are // ordinary editable cells, not decoration). The grid is expanded for the sheet // on screen only — expanding every tab at once would multiply this by the // number of sheets, and the expansion is rebuilt when a tab is activated. const GRID_MIN_COLS = 26; // A … Z — the opening column bound, always materialised const GRID_MIN_ROWS = 1000; // #184 — how far the grid may grow, on demand const GRID_CHUNK_ROWS = 100; // rows materialised per extension step /** * Pad a panel's table up to GRID_MIN_COLS (columns). * Idempotent, and a no-op once the table already reaches that bound. */ const expandPanelGrid = (panel) => { const table = panel.querySelector(".xlsx-table"); if (!table) return; const headRow = table.querySelector("thead tr"); const tbody = table.querySelector("tbody"); if (!headRow || !tbody) return; if (panel.dataset.gridColsPadded === "1") return; panel.dataset.gridColsPadded = "1"; // Columns: add the missing letters after the last one. const firstCol = columnName(1); const lastLetter = (() => { const ths = headRow.querySelectorAll("th:not(.xlsx-corner)"); return ths.length ? ths[ths.length - 1].textContent.trim() : ""; })(); const have = colToIndex(lastLetter) || 0; if (have < GRID_MIN_COLS) { for (let c = have + 1; c <= GRID_MIN_COLS; c++) { const th = document.createElement("th"); th.textContent = columnName(c); th.style.cursor = "pointer"; th.title = t("xlsx.select_column"); headRow.appendChild(th); } } const cols = headRow.querySelectorAll("th:not(.xlsx-corner)").length; // Pad the rows the server already rendered: a sparse/empty sheet arrives // with fewer columns (ex. 8 pour une création) alors que l'en-tête vient // d'être complété jusqu'à Z — sans ce comblement, les lignes 1..N // n'affichent aucune cellule de I à Z. tbody.querySelectorAll("tr").forEach((tr) => { const existing = tr.querySelectorAll("td[data-cell]").length; if (existing >= cols) return; let rowNum = 0; const firstTd = tr.querySelector("td[data-cell]"); if (firstTd) { const p = parseRef(firstTd.dataset.cell); if (p) rowNum = p.row; } if (!rowNum) { const thNum = tr.querySelector("th.xlsx-rownum"); rowNum = Number(thNum ? thNum.textContent.trim() : NaN) || 0; } if (!(rowNum >= 1)) return; const fragRow = document.createDocumentFragment(); for (let c = existing + 1; c <= cols; c++) { const td = document.createElement("td"); td.dataset.cell = `${columnName(c)}${rowNum}`; td.setAttribute("role", "gridcell"); fragRow.appendChild(td); } tr.appendChild(fragRow); }); // Colgroup: the row-number gutter plus one entry per column, so the // explicit widths still apply on the padded grid. let cg = table.querySelector("colgroup.xlsx-cols"); if (cg) cg.replaceChildren(); else { cg = document.createElement("colgroup"); cg.className = "xlsx-cols"; table.prepend(cg); } const gutter = document.createElement("col"); gutter.style.width = "44px"; cg.appendChild(gutter); for (let c = 1; c <= cols; c++) { const col = document.createElement("col"); cg.appendChild(col); } invalidateCellIndex(panel); // Wire the fresh cells exactly like the ones the server sent. if (!readOnly) { tbody.querySelectorAll("td[data-cell]").forEach((td) => { if (td.dataset.wired !== "1") setupCell(td); }); } applyGridA11y(panel); }; /** * #184 — materialise the next block of empty rows (GRID_CHUNK_ROWS at a * time, up to GRID_MIN_ROWS). The grid may reach A → Z × 1000, but only the * rows the reader actually reaches are created: materialising all 1000 up * front means ~26 000 live editable cells per sheet, each one wired, which * pinned ~0.3 GB per render and exhausted the heap in the JSDOM suite. * Returns the number of rows added. */ const padMoreRows = (panel) => { const table = panel.querySelector(".xlsx-table"); const tbody = table && table.querySelector("tbody"); const headRow = table && table.querySelector("thead tr"); if (!tbody || !headRow) return 0; const cols = headRow.querySelectorAll("th:not(.xlsx-corner)").length; const rows = tbody.querySelectorAll("tr").length; if (!cols || rows >= GRID_MIN_ROWS) return 0; const stop = Math.min(rows + GRID_CHUNK_ROWS, GRID_MIN_ROWS); const frag = document.createDocumentFragment(); const added = []; for (let r = rows + 1; r <= stop; r++) { const tr = document.createElement("tr"); tr.setAttribute("role", "row"); const th = document.createElement("th"); th.className = "xlsx-rownum"; th.setAttribute("role", "rowheader"); th.textContent = String(r); th.style.cursor = "pointer"; th.title = t("xlsx.select_row"); tr.appendChild(th); for (let c = 1; c <= cols; c++) { const td = document.createElement("td"); td.dataset.cell = `${columnName(c)}${r}`; td.setAttribute("role", "gridcell"); tr.appendChild(td); } frag.appendChild(tr); added.push(tr); } tbody.appendChild(frag); invalidateCellIndex(panel); if (!readOnly) { added.forEach((tr) => tr.querySelectorAll("td[data-cell]").forEach(setupCell)); } applyGridA11y(panel); return stop - rows; }; const panels = isCsv ? `
${sheets[0]?.html || data.html || ""}
` : sheets.map((s, i) => `` ).join(""); const lossWarning = lossy.length ? `
${escapeHtml(t("xlsx.lossy_title"))} ${lossy .map((f) => `${escapeHtml(t("xlsx.feature_" + f))}`) .join("")} ${escapeHtml(t("xlsx.lossy_hint"))}
` : ""; // #154-A1 — status pills and the grouped command bar are built by // buildCommandBar() (frontend/js/xlsx/command-bar.js). area.innerHTML = `
${menuBarHtml}
${tabs} ${fileHtml}
${statusBar}
${lossWarning}
${panels}
`; // 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). // #185 — `dashBtn` is declared out here so Affichage ▸ Afficher ▸ Tableau de // bord (and Données ▸ Plages nommées) can reach the very same panel. let dashBtn = null; if (!isCsv && !readOnly) { dashBtn = el("button", { class: "btn-action xlsx-dash-btn", id: "xlsx-dashboard-btn", type: "button", title: t("xlsx.dashboard_btn"), }, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]); (area.querySelector("#xlsx-view-group") || area.querySelector(".xlsx-toolbar-actions")).appendChild(dashBtn); const inspector = area.querySelector("#xlsx-inspector"); const inspectorBody = area.querySelector("#xlsx-inspector-body"); const closeInspector = () => { const panel = inspectorBody.querySelector(".xlsx-dashboard"); if (panel) panel.remove(); inspector.classList.remove("open"); inspector.setAttribute("aria-hidden", "true"); dashBtn.classList.remove("active"); }; dashBtn.addEventListener("click", async () => { // #154-A3 — the dashboard lives in a right-hand inspector so the grid // stays visible beside it (it used to be prepended into the grid flow). if (inspectorBody.querySelector(".xlsx-dashboard")) { closeInspector(); return; } dashBtn.classList.add("active"); inspector.classList.add("open"); inspector.setAttribute("aria-hidden", "false"); const panel = document.createElement("div"); panel.className = "xlsx-dashboard"; panel.innerHTML = renderDashboardLoading(); inspectorBody.prepend(panel); try { const dash = await api( `/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`, ); panel.innerHTML = renderDashboardHtml(dash); safeCreateIcons(); // #154-A5 — clicking a named range reveals its first cell in the grid. panel.querySelectorAll(".xlsx-range-row[data-ref]").forEach((row) => { row.addEventListener("click", () => revealRange(row.dataset.ref)); }); } catch (err) { panel.innerHTML = `

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

`; } }); area.querySelector("#xlsx-inspector-close").addEventListener("click", closeInspector); // #154-A3 — the assistant is the existing global side panel; the inspector // just gives it a contextual entry point from the spreadsheet. area.querySelector("#xlsx-inspector-ai").addEventListener("click", () => { openAssistant(); }); // #154-A5 — the inspector is resizable (width restored per session). const resizeHandle = area.querySelector("#xlsx-inspector-resize"); const WIDTH_KEY = "obsigate_xlsx_inspector_width"; const clampWidth = (w) => Math.min(640, Math.max(260, w)); const savedWidth = Number(localStorage.getItem(WIDTH_KEY)); if (savedWidth >= 260 && savedWidth <= 640) { inspector.style.flexBasis = `${savedWidth}px`; inspector.style.width = `${savedWidth}px`; } let resizing = false; const onMove = (e) => { if (!resizing) return; const width = clampWidth(inspector.getBoundingClientRect().right - e.clientX); inspector.style.flexBasis = `${width}px`; inspector.style.width = `${width}px`; }; const onUp = () => { if (!resizing) return; resizing = false; document.body.style.userSelect = ""; localStorage.setItem(WIDTH_KEY, String(Math.round(inspector.getBoundingClientRect().width))); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; resizeHandle.addEventListener("mousedown", (e) => { e.preventDefault(); resizing = true; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }); } const saveBtn = area.querySelector("#xlsx-save-btn"); const panelEls = [...area.querySelectorAll(".xlsx-panel")]; const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length; const refreshSaveState = () => { const dirty = dirtyCount() > 0; saveBtn.disabled = !dirty; saveBtn.classList.toggle("is-dirty", dirty); // #154-A2 — star a sheet tab that holds unsaved edits. panelEls.forEach((panel) => { const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`); if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0); }); }; // #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept // and the user can retry the save without losing their work. const bannerHost = document.createElement("div"); bannerHost.className = "xlsx-banner-host"; area.querySelector(".xlsx-panels").before(bannerHost); const showConflict = (msg) => { bannerHost.innerHTML = ""; const banner = document.createElement("div"); banner.className = "xlsx-banner xlsx-banner-conflict"; banner.setAttribute("role", "alert"); banner.innerHTML = `${escapeHtml(msg)}`; // #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(`
${win.html}
`, "text/html"); // No `tbody` selector: the fragment embeds its own wrapper div, so the // parse yields bare `` inside `` (the rows we want) — while // any `tbody` in the fragment belongs to the *embedded* wrapper table. doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr)); offset = win.offset + win.rows; done = !win.has_more; refreshFoot(); // The new rows must behave like the initial ones: contenteditable, // dirty tracking, cached-value tooltip, icons in the fresh footnote. panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell); panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1")); const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]"); cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); }); // #153 A15 — the window carries its own style/merge metadata: fold it // into the sheet meta and re-apply, so lazy rows look like the initial // render (applySheetMeta is idempotent over already-processed cells). const sheetMeta = sheets[Number(panel.dataset.sheet)]; if (sheetMeta) { Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {}); Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {}); Object.assign(sheetMeta.comments || (sheetMeta.comments = {}), win.comments || {}); Object.assign(sheetMeta.formats || (sheetMeta.formats = {}), win.formats || {}); Object.assign(sheetMeta.colwidths || (sheetMeta.colwidths = {}), win.colwidths || {}); Object.assign(sheetMeta.rowheights || (sheetMeta.rowheights = {}), win.rowheights || {}); 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); refreshPanelFormulas(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(); }); } }; // ── Formula engine: local evaluation of `=…` cells ─────────────────── // The viewer computes formulas itself (frontend/js/xlsx/formula.js, no // round-trip): display values update live while the raw formula stays in // `td.dataset.formula` (what gets saved). Anything the parser rejects // keeps its original rendering — Excel remains the source of truth. const formulaLocale = () => { try { return getLocale() === "fr" ? "fr" : "en"; } catch (_) { return "en"; } }; /** User-typed text of a cell (helper spans excluded). */ const cleanCellText = (td) => { if (!td) return ""; const clone = td.cloneNode(true); clone.querySelectorAll(".xlsx-cached,.xlsx-comment-dot").forEach((el) => el.remove()); return clone.textContent; }; /** Storable content: the formula when the engine manages the cell. */ const cellRaw = (td) => (td && td.dataset.formula) || cleanCellText(td); const barTextOf = (td) => cellRaw(td); const panelSheetName = (panel) => { const idx = Number(panel && panel.dataset ? panel.dataset.sheet : NaN); return (Number.isFinite(idx) && sheets[idx] ? sheets[idx].name : "") || ""; }; const panelBySheet = (name) => { const idx = sheets.findIndex((s) => s && s.name === name); if (idx < 0) return null; return panelEls.find((p) => Number(p.dataset.sheet) === idx) || null; }; const readRawCell = (sheetName, ref) => { const panel = panelBySheet(sheetName); if (!panel) return undefined; // unknown sheet → #REF!, not blank const td = panel.querySelector(`td[data-cell="${String(ref).toUpperCase()}"]`); if (!td) return null; return cellRaw(td); }; const decorateFormulaCell = (td, value, locale) => { td.querySelectorAll(".xlsx-cached").forEach((el) => el.remove()); if (value && value.t === "link") { const a = document.createElement("a"); a.href = value.url; a.className = "xlsx-formula-link"; a.rel = "noopener"; a.textContent = value.label; a.title = value.url; td.textContent = ""; td.appendChild(a); } else { td.textContent = displayValue(value, locale); } td.dataset.display = td.textContent; }; /** * Adopt `raw` as the cell's content: manage formulas, release the rest. * With `render=false` (keystrokes in flight) only the identity is adopted — * dependents read the latest valid formula while the caret is untouched. */ const adoptCellRaw = (td, raw, scope, render = true) => { const locale = scope.locale; if (!isFormulaText(raw)) { delete td.dataset.formula; delete td.dataset.display; return false; } let ast; try { ast = parseFormula(raw); } catch (_) { // Half-typed while editing: keep the previous identity so dependents // stay stable; once committed, release so the text saves as typed. if (render) { delete td.dataset.formula; delete td.dataset.display; } return false; } let value; try { value = evaluate(ast, scope); } catch (_) { if (render) { delete td.dataset.formula; delete td.dataset.display; } return false; } // Unknown function but Excel left a cached result: defer to it instead // of showing #NOM? — the shadow stays the display (A12 fallback). if (value && value.t === "e" && value.code === "NAME" && td.querySelector(".xlsx-cached[data-cached-value]")) { delete td.dataset.formula; delete td.dataset.display; return false; } td.dataset.formula = raw.trim(); if (render) { decorateFormulaCell(td, value, locale); decorateComments(td); } return true; }; const refreshPanelFormulas = (panel, opts = {}) => { if (!panel || !panel.isConnected) return; // Frappe en cours (`live`) : sans cellule gérée par le moteur, aucun // affichage ne peut changer — les dépendants n'existent que via // `dataset.formula`. On évite alors le balayage des ~26 000 cellules à // chaque touche (le commit au `blur` reste, lui, un balayage complet qui // adopte les nouvelles formules). Test natif, arrêté au premier succès. // (`opts` peut recevoir l'index d'un `forEach` : accès défensif.) if (opts && opts.live && !panel.querySelector("td[data-formula]")) return; const locale = formulaLocale(); const sheet = panelSheetName(panel); const scope = createScope({ readRaw: readRawCell, sheet, locale }); panel.querySelectorAll("td[data-cell]").forEach((td) => { // #184 — a padded grid is mostly empty: an empty cell without a managed // formula can never produce a value, so skip it BEFORE the expensive // cellRaw()/cloneNode() path. Without this, one keystroke would clone // 26 000 nodes. if (!td.dataset.formula && !td.textContent) return; // While the user is typing in the cell, adopt the identity (so // dependents follow) but never rewrite its text under the caret. // Programmatic writes carry editing=1 without focus: those render fully. const typing = td === document.activeElement && td.dataset.editing === "1"; if (typing) { const raw = cleanCellText(td); if (isFormulaText(raw) || td.dataset.formula) adoptCellRaw(td, raw, scope, false); return; } const managed = Boolean(td.dataset.formula); if (managed && td.textContent === (td.dataset.display ?? "")) { adoptCellRaw(td, td.dataset.formula, scope); // deps may have changed } else if (managed) { // The user overwrote the display: adopt the CURRENT text, never the // stale dataset (cellRaw would hand back the old formula). adoptCellRaw(td, cleanCellText(td), scope); } else { const raw = cellRaw(td); if (isFormulaText(raw)) adoptCellRaw(td, raw, scope); } }); }; const refreshAllFormulas = () => { panelEls.forEach(refreshPanelFormulas); }; /** Reflect the active cell's toggles (bold/italic/strike/underline, font size). */ const updateSheetsToolbarStates = () => { const td = activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null; const setToggle = (id, on) => { const b = area.querySelector(`#${id}`); if (!b) return; b.classList.toggle("active", Boolean(on)); b.setAttribute("aria-pressed", String(Boolean(on))); }; setToggle("xs-b", td && (td.style.fontWeight === "600" || td.style.fontWeight === "bold" || td.style.fontWeight === "700")); setToggle("xs-i", td && td.style.fontStyle === "italic"); setToggle("xs-u", td && (td.style.textDecoration || "").includes("underline")); setToggle("xs-s", td && (td.style.textDecoration || "").includes("line-through")); const sizeEl = area.querySelector("#xs-fssize"); if (sizeEl) { const px = td && td.style.fontSize ? Number.parseFloat(td.style.fontSize) : NaN; const size = String(Number.isFinite(px) ? Math.round(px) : 11); if (![...sizeEl.options].some((o) => o.value === size)) { const opt = document.createElement("option"); opt.value = size; opt.textContent = size; sizeEl.appendChild(opt); } sizeEl.value = size; } const wrapBtn = area.querySelector("#xs-wrap"); if (wrapBtn) { const on = td && td.style.whiteSpace === "normal"; wrapBtn.classList.toggle("active", Boolean(on)); wrapBtn.setAttribute("aria-pressed", String(Boolean(on))); } }; // ── #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 || ""; // #181-A2 / #182 — the selected block is outlined on its four real edges. const SELECT_EDGE_CLASSES = [ "xlsx-sel-top", "xlsx-sel-bottom", "xlsx-sel-left", "xlsx-sel-right", ]; // #182 — class selectors need their leading dot: a bare `xlsx-sel-top` is a // TYPE selector (an `` element), so the query matched nothing // and a previous outline was never cleared — the boxes piled up on every // focus change. (Le balayage global est désormais remplacé par `clearMarks` // ci-dessous : plus aucun `querySelectorAll` sur toute la grille.) // Marques de sélection incrémentales : la grille compte ~26 000 cellules, et // chaque changement de sélection (focus, flèche, survol pendant un glisser) // balayait tout le panneau en `querySelectorAll` pour effacer les anciennes // marques — d'où les saccades, et les indicateurs fantômes quand le balayage // ratait sa cible (ex. `remove(".xlsx-hdr-selected")` avec un point : le // sélecteur ne correspondait jamais). Seuls les nœuds précédemment marqués // sont désormais retouchés : O(sélection) au lieu de O(grille). let lastMarks = { cells: [], edges: [], headers: [] }; // Racine visuelle du viewer : l'état « sélection multiple » est porté par // `.xlsx-viewer` (et non par `area` = #content-area, qui est le parent) — // le sélecteur CSS `.xlsx-viewer.xlsx-range-multi` exige les deux classes // sur le même élément. Cache résolu paresseusement : applySelection() tourne // à chaque mousemove d'un glisser. let viewerRootEl = null; const viewerRoot = () => { if (!viewerRootEl || !viewerRootEl.isConnected) { viewerRootEl = area.querySelector(".xlsx-viewer") || area; } return viewerRootEl; }; const clearMarks = () => { for (const td of lastMarks.cells) { if (td && td.classList) td.classList.remove("xlsx-selected"); } for (const td of lastMarks.edges) { if (td && td.classList) td.classList.remove(...SELECT_EDGE_CLASSES); } for (const el of lastMarks.headers) { if (el && el.classList) el.classList.remove("xlsx-hdr-selected"); } // Plus de sélection → plus de teinte multi-cellules. viewerRoot().classList.remove("xlsx-range-multi"); lastMarks = { cells: [], edges: [], headers: [] }; }; const clearSelectionClasses = () => { clearMarks(); }; 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. // #181-A2 — the selected cells carry `xlsx-selected` (a tint) plus the // `xlsx-sel-*` edge classes, which trace the block exactly the way // Excel/Sheets draw it, instead of a flat wash over every cell. const paintSelectionEdges = (panel, b) => { 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) continue; let touched = false; if (r === b.r1) { td.classList.add("xlsx-sel-top"); touched = true; } if (r === b.r2) { td.classList.add("xlsx-sel-bottom"); touched = true; } if (c === b.c1) { td.classList.add("xlsx-sel-left"); touched = true; } if (c === b.c2) { td.classList.add("xlsx-sel-right"); touched = true; } if (touched) lastMarks.edges.push(td); } } }; const applySelection = () => { if (!selection) return; const { panel } = selection; const b = selectionBounds(); clearMarks(); // Google Sheets : la teinte de fond n'apparaît qu'à partir de DEUX // cellules (`.xlsx-range-multi` sur le viewer). Une sélection d'une seule // cellule ne garde que son cadre accent (xlsx-sel-* + xlsx-active), sans // changer le fond de la cellule. viewerRoot().classList.toggle("xlsx-range-multi", b.r1 !== b.r2 || b.c1 !== b.c2); 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"); lastMarks.cells.push(td); } } } // #181-A2 — the range outline, plus the header highlight so the selected // row numbers / column letters read as selected too. paintSelectionEdges(panel, b); paintSelectedHeaders(panel, b); activeCellEl.value = rangeLabel(); updateMinibar(); }; // Highlight the headers covered by the current range (`A`, `B`… / `1`, `2`…). const paintSelectedHeaders = (panel, b) => { const head = panel.querySelector(".xlsx-table thead tr"); if (head) { [...head.querySelectorAll("th:not(.xlsx-corner)")].forEach((th, i) => { if (i + 1 >= b.c1 && i + 1 <= b.c2) { th.classList.add("xlsx-hdr-selected"); lastMarks.headers.push(th); } }); } panel.querySelectorAll("th.xlsx-rownum").forEach((th) => { const row = Number(th.textContent); if (row >= b.r1 && row <= b.r2) { th.classList.add("xlsx-hdr-selected"); lastMarks.headers.push(th); } }); }; 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; }; // La cellule portant le contour : suivie par référence plutôt que par un // `querySelector` sur toute la grille à chaque déplacement. `markedActiveTd` // (le contour) est distinct d'`activeTd` (le focus logique) : les deux ne // divergent que sur les chemins d'extension de plage, et le contour reste // nettoyé dans tous les cas, sans balayage. let markedActiveTd = null; const unmarkActive = () => { if (markedActiveTd && markedActiveTd.classList) markedActiveTd.classList.remove("xlsx-active"); markedActiveTd = null; }; const setActiveCell = (td) => { unmarkActive(); activeTd = td || null; if (!td) { activeCellEl.value = ""; formulaInput.value = ""; formulaInput.disabled = true; setSelection(null); return; } td.classList.add("xlsx-active"); markedActiveTd = td; activeCellEl.value = cellName(td); formulaInput.disabled = false; formulaInput.value = barTextOf(td); setSelection(td); updateSheetsToolbarStates(); }; const syncFormulaBar = (td) => { if (td !== activeTd) return; if (!syncing.value) formulaInput.value = barTextOf(td); }; // #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. // #181-A1/A2 — navigation from a SELECTED cell. Shift+arrow extends the range // from the anchor (#181-A2); a plain arrow collapses to a single cell. In both // cases the cell stays selected, never editing. 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 // Leaving edit mode: a move always commits whatever was being typed. if (isTypingInCell(td)) { commitEdit(); exitEditing(td); } // #155/#181-A2 — 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 = barTextOf(next); return; } 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(); refreshPanelFormulas(activeTd.closest(".xlsx-panel")); }); 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(); refreshPanelFormulas(activeTd.closest(".xlsx-panel")); 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, beforeFormula = null, afterFormula = null) => { if (before === after && (beforeFormula ?? null) === (afterFormula ?? null)) return; pushHistory({ kind: "cell", sheet: panelKey(td.closest(".xlsx-panel")), ref: td.dataset.cell, before, after, beforeFormula: beforeFormula ?? null, afterFormula: afterFormula ?? null, }); }; const commitEdit = () => { if (!pendingEdit) return; const { td, before, beforeFormula } = pendingEdit; pendingEdit = null; recordEdit(td, before, td.textContent, beforeFormula ?? null, td.dataset.formula ?? null); }; const beginEdit = (td) => { if (pendingEdit && pendingEdit.td !== td) commitEdit(); if (!pendingEdit) pendingEdit = { td, before: td.textContent, beforeFormula: td.dataset.formula ?? null }; }; 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; const formulaKey = value === entry.after ? "afterFormula" : "beforeFormula"; if (entry[formulaKey] !== undefined) { if (entry[formulaKey] == null) delete td.dataset.formula; else td.dataset.formula = entry[formulaKey]; delete td.dataset.display; } td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? "")); if (panel) refreshPanelFormulas(panel); 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. // #181-A1 — set by enterEditing() right before it focuses the cell, so the // cell's own `focus` handler knows the focus is part of entering edit mode // (and does not reset the state we just installed). let editingRequested = false; 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; // #184 — explicit wiring marker. `dataset.orig` cannot serve: it is "" for // an empty cell, so the padding pass would wire the server cells a second // time and double every listener. td.dataset.wired = "1"; // #181-A1 — every cell starts in SELECTION mode: no text caret, just the // outline. Editing is a second, explicit state (double-click, F2, first // printable keystroke) that reveals the caret and accepts typing. td.dataset.editing = ""; td.classList.add("xlsx-cell"); 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). enterEditing(td); td.classList.add("xlsx-dirty"); syncFormulaBar(td); refreshSaveState(); // `live` : la frappe reste fluide même sur une grande grille (voir // refreshPanelFormulas — le commit au blur recalcule tout). refreshPanelFormulas(td.closest(".xlsx-panel"), { live: true }); }); // #181-A1 — double-click is the pointer route into editing: the caret must // appear where the user pointed (native caret placement, we only lift the // caret colour). td.addEventListener("dblclick", (e) => { e.preventDefault(); enterEditing(td); placeCaretAtPoint(td, e.clientX, e.clientY); }); td.addEventListener("focus", () => { // Focusing alone never starts editing: the cell is only SELECTED — unless // enterEditing() is what asked for the focus (double-click, F2, first // keystroke), otherwise this handler would undo the edit it just started. if (!editingRequested) td.dataset.editing = ""; editingRequested = false; 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 = barTextOf(td); return; } setActiveCell(td); }); td.addEventListener("blur", () => { commitEdit(); exitEditing(td); refreshPanelFormulas(td.closest(".xlsx-panel")); }); 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(); enterEditing(td); 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; } // #181-A1 — the FIRST printable keystroke switches the selected cell to // editing and REPLACES its content, exactly like Google Sheets / Excel // (typing never appends to an existing value). `keydown` runs before the // browser inserts, so the old content is dropped first; the incoming // character is then inserted normally by the contenteditable. if (e.key.length === 1 && !mod && !isTypingInCell(td)) { enterEditing(td, { replace: true }); beginEdit(td); return; } if (e.key === "Enter" && !e.shiftKey) { // #181-A3 — Enter is contextual, like Sheets/Excel: // · while EDITING → commit and move to the cell BELOW; // · while SELECTED → switch the current cell to editing mode. e.preventDefault(); if (isTypingInCell(td)) { const panel = td.closest(".xlsx-panel"); commitEdit(); exitEditing(td); const ref = parseRef(cellName(td)); const below = ref ? findTd(panel, ref.row + 1, ref.col) : null; if (below) goTo(below); else { td.blur(); td.focus(); } } else { startEditing(td); } return; } if (e.key === "Escape") { e.preventDefault(); // #181-A1 — Escape leaves editing and restores the value. td.textContent = td.dataset.orig; td.classList.remove("xlsx-dirty"); exitEditing(td); syncFormulaBar(td); refreshSaveState(); refreshPanelFormulas(td.closest(".xlsx-panel")); return; } if (move) { // #181-A1 — arrows/Tab only MOVE while the cell is selected; while // editing they belong to the caret (except Tab, handled below). if (isTypingInCell(td) && !e.key.startsWith("Arrow")) { // Tab while editing: commit then advance, like Sheets. e.preventDefault(); const panel = td.closest(".xlsx-panel"); commitEdit(); exitEditing(td); const forward = !e.shiftKey; const next = findTd(panel, parseRef(cellName(td))?.row, parseRef(cellName(td))?.col + (forward ? 1 : -1)); if (next) goTo(next); return; } if (isTypingInCell(td) && e.key.startsWith("Arrow")) { if (e.altKey) { // #181 — Alt+flèche inserts a line break inside the cell (Sheets). e.preventDefault(); insertTextAtCaret(td, "\n"); return; } // #182 — in Google Sheets / Excel an arrow key during editing // COMMITS the cell and moves the selection (the caret is not walked // through the text; Alt+flèche covers the line break). Leaving the // caret here made the focused cell look frozen: the outline stayed // on the cell being edited instead of following the navigation. e.preventDefault(); moveActive(td, e.key, e.key === "ArrowRight" || e.key === "ArrowDown", false); return; } // 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); refreshAllFormulas(); 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.span || 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"; }); // #184 — the sheet coming on screen gets its A → Z × 1000 grid. ensureGridPadded(visiblePanel()); refreshPanelFormulas(visiblePanel()); updateSheetsToolbarStates(); updateMinibar(); requestStick(); }); }); // =LIEN() cells render a real anchor: plain click selects the cell (the // grid stays editable), Ctrl/Cmd+click follows the link like a spreadsheet. const onFormulaLinkClick = (e) => { const a = e.target && e.target.closest ? e.target.closest("a.xlsx-formula-link") : null; if (!a) return; e.preventDefault(); if (e.ctrlKey || e.metaKey) { window.open(a.href, "_blank", "noopener"); } else { const td = a.closest("td[data-cell]"); if (td) td.focus(); } }; area.addEventListener("click", onFormulaLinkClick); // Replaced on the next render — the teardown at the top of this function // removes it so the grid it closes over can be collected. area._xlsxDropFormulaLinkClick = () => area.removeEventListener("click", onFormulaLinkClick); // 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 () => { // Commit whatever is being typed (cell or formula bar) so the collector // below reads reconciled content: a focused formula cell still carries // its previous dataset.formula while its text already changed. if (document.activeElement && typeof document.activeElement.blur === "function") { document.activeElement.blur(); } // An editing flag without a blur (programmatic write, missed event) // would freeze the cell out of the recompute below: drop it, the blur // above already committed genuine edits. area.querySelectorAll('td[data-cell][data-editing="1"]').forEach((td) => { delete td.dataset.editing; }); panelEls.forEach(refreshPanelFormulas); // 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] = cellRaw(td); }); 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; // Our own write: the grid already reflects it (dynamic updates), so the // watcher echo must not trigger a full reload with spinner (utils.js). window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() }; 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"); } } }); // #185 — the Fichier ▸ Télécharger ▸ entry lives in the menu bar now; the // button id is unchanged, so this stays the single Download implementation // and the menu just closes. const downloadOriginalFile = () => { window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank"); }; // ── #153 A13 — sort / filter / find in the sheet + CSV export ────────── // ALL of these act on the RENDERED grid only: the workbook is never // rewritten by a sort or a filter (the save pipeline stays the only write // path, and the note in xlsx.sort_applied says so). const visiblePanel = () => panelEls.find((p) => p.style.display !== "none") || panelEls[0]; // Sorting reorders rows by the text of one column. Dirty cells travel // with their row, so a sort never loses an unsaved edit. let sortState = null; // { col, dir } on the visible panel const applySort = (panel, col, dir) => { const tbody = panel.querySelector(".xlsx-table tbody"); if (!tbody) return; const rows = [...tbody.querySelectorAll("tr")]; const 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 }); } refreshPanelFormulas(panel); 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 = barTextOf(td); } }; 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 = barTextOf(td); } }; const wireHeaderSelection = (panel) => { const table = panel.querySelector(".xlsx-table"); if (!table || table.dataset.selectWired) return; table.dataset.selectWired = "1"; // Delegated (not per-TH): headers created later by structure surgery // resolve their live index, so no stale closure can select the wrong // column after an insert or a delete. const paintHeaders = () => { table.querySelectorAll("thead th:not(.xlsx-corner)").forEach((th) => { th.style.cursor = "pointer"; th.title = t("xlsx.select_column"); }); table.querySelectorAll("th.xlsx-rownum").forEach((th) => { th.style.cursor = "pointer"; th.title = t("xlsx.select_row"); }); }; paintHeaders(); // #184 — click a header = whole row/column; DRAG over the headers (or // Shift+click) extends to several rows / several columns, the way Sheets // and Excel do. The selection is expressed with the same {anchor, focus} // model as a range, so every downstream command (copier, effacer, menu) // keeps working unchanged. const colCount = () => table.querySelectorAll("thead th:not(.xlsx-corner)").length; const rowCount = () => panel.querySelectorAll("th.xlsx-rownum").length; const colIndexOf = (th) => { const head = table.querySelector("thead tr"); if (!head) return 0; return [...head.children].indexOf(th); }; let headerAnchor = null; // { axis, from } — kept after the drag so Shift+click extends let headerDragging = false; // `true` entre le `mousedown` et le `click` d'une vraie souris : le clic // ne refait alors rien (`mousedown` a déjà sélectionné, y compris le // glisser et le Shift+clic). Sans `mousedown` préalable (clic synthétique, // activation clavier, événement avalé en amont), le repli `click` // ci-dessous sélectionne. let headerPressed = false; const selectAllFromMargin = () => { const cols = colCount() || 1; const rows = rowCount() || 1; selection = { panel, anchor: { row: 1, col: 1 }, focus: { row: rows, col: cols } }; applySelection(); const head = findTd(panel, 1, 1); activeTd = head; if (head) { formulaInput.disabled = false; formulaInput.value = barTextOf(head); } updateSheetsToolbarStates(); updateMinibar(); }; const cornerFrom = (e) => { const corner = e.target.closest ? e.target.closest(".xlsx-corner") : null; return corner && table.contains(corner) ? corner : null; }; const selectHeaderRange = (axis, from, to) => { const cols = colCount() || 1; const rows = rowCount() || 1; const lo = Math.max(1, Math.min(from, to)); const hi = Math.max(from, to); if (axis === "row") { selection = { panel, anchor: { row: lo, col: 1 }, focus: { row: hi, col: cols } }; } else { selection = { panel, anchor: { row: 1, col: lo }, focus: { row: rows, col: hi } }; } applySelection(); // The active cell is the first cell of the selected block. const head = axis === "row" ? findTd(panel, lo, 1) : findTd(panel, 1, lo); activeTd = head; if (head) { formulaInput.disabled = false; formulaInput.value = barTextOf(head); } updateSheetsToolbarStates(); updateMinibar(); }; // A press on a header edge belongs to the resize gesture, not to the // selection: the two would otherwise fight over the same mousedown. // Synthetic events (tests, keyboard activation) carry no pointer position // (`isTrusted === false`, coords at 0) : sans cette garde, elles tombent // toujours « près du bord » et la sélection ne se fait jamais. const nearResizeEdge = (th, axis, e) => { if (e && e.isTrusted === false) return false; const r = th.getBoundingClientRect(); const edge = 8; // RESIZE_EDGE_PX return axis === "col" ? Math.abs(e.clientX - r.right) <= edge : Math.abs(e.clientY - r.bottom) <= edge; }; const headerFrom = (e) => { const colTh = e.target.closest ? e.target.closest(".xlsx-table thead th:not(.xlsx-corner)") : null; if (colTh && table.contains(colTh)) { return nearResizeEdge(colTh, "col", e) ? null : { axis: "col", index: colIndexOf(colTh), th: colTh }; } const rowTh = e.target.closest ? e.target.closest("th.xlsx-rownum") : null; if (rowTh && table.contains(rowTh)) { const n = Number(rowTh.textContent); return Number.isFinite(n) && n >= 1 && !nearResizeEdge(rowTh, "row", e) ? { axis: "row", index: n, th: rowTh } : null; } return null; }; table.addEventListener("mousedown", (e) => { if (e.button !== 0) return; if (cornerFrom(e)) { e.preventDefault(); headerPressed = true; headerAnchor = null; headerDragging = false; selectAllFromMargin(); return; } const h = headerFrom(e); if (!h) return; e.preventDefault(); headerPressed = true; // A cell under the pointer may still hold the focus: the selection is // driven from here, not from the focus event. if (activeTd && isTypingInCell(activeTd)) { commitEdit(); exitEditing(activeTd); } if (e.shiftKey && headerAnchor && headerAnchor.axis === h.axis) { selectHeaderRange(h.axis, headerAnchor.from, h.index); headerDragging = false; return; } headerAnchor = { axis: h.axis, from: h.index }; headerDragging = true; selectHeaderRange(h.axis, h.index, h.index); }); table.addEventListener("mouseover", (e) => { if (!headerDragging || !headerAnchor) return; const h = headerFrom(e); if (!h || h.axis !== headerAnchor.axis) return; selectHeaderRange(headerAnchor.axis, headerAnchor.from, h.index); }); const endHeaderDrag = () => { headerDragging = false; }; table.addEventListener("mouseup", endHeaderDrag); table.addEventListener("mouseleave", endHeaderDrag); // Repli `click` : quand aucun `mousedown` ne l'a précédé (clic synthétique // des tests, activation clavier, événement avalé en amont), le clic // sélectionne — sinon il ne fait que consommer le drapeau, pour ne jamais // réduire un glisser ou un Shift+clic qui vient d'avoir lieu. table.addEventListener("click", (e) => { if (headerPressed) { headerPressed = false; return; } if (cornerFrom(e)) { selectAllFromMargin(); return; } const h = headerFrom(e); if (!h) return; headerAnchor = { axis: h.axis, from: h.index }; selectHeaderRange(h.axis, h.index, h.index); }); }; 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); // ── Column/row resize by dragging header edges (mouse + touch) ───────── // Sizes persist through the same locked rewrite as the Mise en forme menu // (PUT …/xlsx/style col_width/row_height); the re-render applies them from // the colwidths/rowheights metadata. No undo entry (like the size prompts). // The drag context is created by the render that starts it (fresh runStyle // and revision) while pointer routing lives at module level, so re-renders // never leave stale closures behind. const RESIZE_EDGE_PX = 8; const resizeGhost = (hostArea) => { let g = hostArea.querySelector(".xlsx-resize-ghost"); if (!g) { g = document.createElement("div"); g.className = "xlsx-resize-ghost"; g.hidden = true; hostArea.appendChild(g); } return g; }; const moveResizeGhost = (d, pos) => { // Fixed overlay: client coordinates straight from the pointer. const g = resizeGhost(d.area); const grid = d.panel.querySelector(".csv-table-wrapper"); const r = grid ? grid.getBoundingClientRect() : { top: 0, left: 0, height: 0, width: 0 }; if (d.kind === "col") { g.dataset.kind = "col"; g.style.left = `${pos}px`; g.style.top = `${r.top}px`; g.style.height = `${Math.max(0, r.height)}px`; } else { g.dataset.kind = "row"; g.style.top = `${pos}px`; g.style.left = `${r.left}px`; g.style.width = `${Math.max(0, r.width)}px`; } g.hidden = false; }; const wireResize = (panel) => { const table = panel.querySelector(".xlsx-table"); if (!table || table.dataset.resizeWired || !editable) return; table.dataset.resizeWired = "1"; const headRow = table.querySelector("thead tr"); const colAtEdge = (clientX) => { const ths = [...headRow.querySelectorAll("th:not(.xlsx-corner)")]; for (const th of ths) { const r = th.getBoundingClientRect(); if (Math.abs(clientX - r.right) <= RESIZE_EDGE_PX) { return { th, col: [...headRow.children].indexOf(th) }; } } return null; }; const rowAtEdge = (clientY) => { const nums = [...panel.querySelectorAll("th.xlsx-rownum")]; for (const th of nums) { const r = th.getBoundingClientRect(); if (Math.abs(clientY - r.bottom) <= RESIZE_EDGE_PX) { const row = Number(th.textContent); if (row >= 1) return { th, row }; } } return null; }; const beginDrag = (kind, el, startPx, index) => { if (activeResizeDrag) return; const rect = el.getBoundingClientRect(); const sheet = sheets[Number(panel.dataset.sheet)]?.name || ""; const d = { kind, area, panel, index, sheet, suppressClick: false, startPx, startSize: kind === "col" ? rect.width : rect.height, lastPx: startPx, moved: false, move(clientX, clientY) { const pos = this.kind === "col" ? clientX : clientY; if (Math.abs(pos - this.startPx) > 3) this.moved = true; this.lastPx = pos; moveResizeGhost(this, pos); }, commit() { const g = this.area.querySelector(".xlsx-resize-ghost"); if (g) g.hidden = true; if (!this.moved) return; this.suppressClick = true; const px = Math.max(1, this.startSize + ((this.lastPx ?? this.startPx) - this.startPx)); if (this.kind === "col") { const width = Math.min(255, Math.max(3, Math.round((px / 7) * 10) / 10)); runStyle([{ op: "col_width", sheet: this.sheet, col: columnName(this.index), width }]); } else { const height = Math.min(409, Math.max(9, Math.round(px * 0.75 * 10) / 10)); runStyle([{ op: "row_height", sheet: this.sheet, row: this.index, height }]); } }, cancel() { const g = this.area.querySelector(".xlsx-resize-ghost"); if (g) g.hidden = true; }, }; activeResizeDrag = d; moveResizeGhost(d, startPx); }; // #180 — the edge header mirrors the resize cursor itself: the column // headers carry an inline `cursor:pointer` (selection) and the cells a CSS // `cursor:cell`, both of which would otherwise cover the table-level // cursor exactly where the icon matters. The table classes below repaint // the cells through the stylesheet (see `.xlsx-resize-col/row`). let edgeTh = null; const setEdgeTh = (th, cursor) => { if (edgeTh && edgeTh !== th) { edgeTh.style.cursor = edgeTh.dataset.prevCursor || ""; delete edgeTh.dataset.prevCursor; edgeTh = null; } if (!th) return; if (edgeTh !== th) { edgeTh = th; edgeTh.dataset.prevCursor = edgeTh.style.cursor || ""; } if (edgeTh.style.cursor !== cursor) edgeTh.style.cursor = cursor; }; const paintResizeCursor = (kind, th) => { table.style.cursor = kind === "col" ? "col-resize" : kind === "row" ? "row-resize" : ""; table.classList.toggle("xlsx-resize-col", kind === "col"); table.classList.toggle("xlsx-resize-row", kind === "row"); setEdgeTh(th || null, kind === "col" ? "col-resize" : "row-resize"); }; // Excel / Sheets : on ne redimensionne qu'en prenant le BORD d'un // en-tête (lettre de colonne / gutter des numéros). Le corps de la grille // reste réservé à la sélection : auparavant une pression à 3 px d'une // limite lançait un glisser (ghost + aucune cellule sélectionnée). // The geometric probe needs a header under the pointer — except when the // pointer is on the table itself (padding), which names no header; the // hit test decides, as it does when the engine reports no layout. const overColHeader = (target) => !!(target && (target === table || (target.closest && target.closest("thead th")))); const overRowHeader = (target) => !!(target && (target === table || (target.closest && target.closest("th.xlsx-rownum")))); table.addEventListener("mousemove", (e) => { if (activeResizeDrag || !editable || e.buttons) return; const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null; if (c) { paintResizeCursor("col", c.th); return; } const rw = overRowHeader(e.target) ? rowAtEdge(e.clientY) : null; paintResizeCursor(rw ? "row" : "", rw ? rw.th : null); }); table.addEventListener("mouseleave", () => { if (!activeResizeDrag) paintResizeCursor("", null); }); table.addEventListener("mousedown", (e) => { if (!editable || e.button !== 0 || activeResizeDrag) return; const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null; const rw = !c && overRowHeader(e.target) ? rowAtEdge(e.clientY) : null; if (!c && !rw) return; e.preventDefault(); if (c) beginDrag("col", c.th, e.clientX, c.col); else beginDrag("row", rw.th, e.clientY, rw.row); }); table.addEventListener("touchstart", (e) => { if (!editable || activeResizeDrag || !e.touches || !e.touches[0]) return; const t0 = e.touches[0]; const c = overColHeader(e.target) ? colAtEdge(t0.clientX) : null; const rw = !c && overRowHeader(e.target) ? rowAtEdge(t0.clientY) : null; if (!c && !rw) return; // plain tap / long-press keep their behaviour e.preventDefault(); e.stopPropagation(); // no long-press menu mid-drag if (c) beginDrag("col", c.th, t0.clientX, c.col); else beginDrag("row", rw.th, t0.clientY, rw.row); }, { passive: false }); // Excel (AutoFit) et Sheets (« Fit to data ») : un double-clic sur le bord // d'un en-tête ajuste la colonne / la ligne au contenu. Sur une cellule, // le double-clic reste celui de l'édition (#181-A1) : le geste n'est pris // ici que lorsqu'il part d'un en-tête. table.addEventListener("dblclick", (e) => { if (!editable) return; const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null; if (c) { autoFitColumns(panel, [c.col]); return; } const rw = overRowHeader(e.target) ? rowAtEdge(e.clientY) : null; if (rw) autoFitRows(panel, [rw.row]); }); }; panelEls.forEach(wireResize); // #184 — lay the sheet out like a spreadsheet: columns A → Z, and rows that // grow up to 1000 as the reader reaches the end (padMoreRows). Done for the // visible sheet, on tab activation. A truncated sheet keeps its server-driven // "Charger la suite" window: those rows are real data, not empty padding. const ensureGridPadded = (panel) => { if (!panel || isCsv) return; expandPanelGrid(panel); applySheetMeta(panel); refreshPanelFormulas(panel); const meta = sheets[Number(panel.dataset.sheet)] || {}; const wrapper = panel.querySelector(".csv-table-wrapper"); if (meta.truncated || !wrapper || panel.dataset.gridGrowth === "1") return; panel.dataset.gridGrowth = "1"; wrapper.addEventListener("scroll", () => { if (wrapper.scrollTop + wrapper.clientHeight < wrapper.scrollHeight - 120) return; if (padMoreRows(panel)) applySheetMeta(panel); }, { passive: true }); }; ensureGridPadded(visiblePanel()); // L'en-tête A–Z glissait sous la toolbar sticky (z-30 > z-3) dès que la page // défilait — d'autant plus que la barre d'édition a agrandi la toolbar. Le // `top` sticky de l'en-tête suit donc le recouvrement réel (`--xlsx-stick`), // mesuré au scroll / redimensionnement et coalescé en rAF. if (area._xlsxStickCleanup) { try { area._xlsxStickCleanup(); } catch (_) { /* ignore */ } area._xlsxStickCleanup = null; } const syncStick = () => { const toolbar = area.querySelector(".xlsx-toolbar"); const panel = typeof visiblePanel === "function" ? visiblePanel() : null; const wrapper = panel ? panel.querySelector(".csv-table-wrapper") : null; if (!toolbar || !wrapper || !area.isConnected) return; const overlap = toolbar.getBoundingClientRect().bottom - wrapper.getBoundingClientRect().top; area.style.setProperty("--xlsx-stick", `${Math.max(0, Math.round(overlap))}px`); }; let stickRaf = 0; const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame.bind(window) : (cb) => setTimeout(cb, 0); const requestStick = () => { if (stickRaf) return; stickRaf = raf(() => { stickRaf = 0; syncStick(); }); }; const stickCleanups = []; const scroller = getContentArea(); if (scroller && typeof scroller.addEventListener === "function") { const onScroll = () => requestStick(); scroller.addEventListener("scroll", onScroll, { passive: true }); stickCleanups.push(() => scroller.removeEventListener("scroll", onScroll)); } if (typeof window !== "undefined" && typeof window.addEventListener === "function") { const onWinScroll = () => requestStick(); window.addEventListener("scroll", onWinScroll, { passive: true }); stickCleanups.push(() => window.removeEventListener("scroll", onWinScroll)); } if (typeof ResizeObserver === "function") { const toolbar = area.querySelector(".xlsx-toolbar"); if (toolbar) { const ro = new ResizeObserver(() => requestStick()); ro.observe(toolbar); stickCleanups.push(() => ro.disconnect()); } } area._xlsxStickCleanup = () => { stickCleanups.forEach((fn) => { try { fn(); } catch (_) { /* ignore */ } }); if (stickRaf && typeof cancelAnimationFrame === "function") { try { cancelAnimationFrame(stickRaf); } catch (_) { /* ignore */ } } stickRaf = 0; }; requestStick(); 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 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 } }; // ── Dynamic structure application (no re-render) ─────────────────────── // Row/column inserts and deletes are replayed in the DOM (renumber refs, // shift formula texts like Excel, patch the meta maps) so the grid, focus // and selection survive. Sheet add/rename/delete/duplicate rebuild only // their own tab and panel. const shiftSingleCoord = (ref, op) => { // Returns the shifted "A1" ref, or null when it falls into a deletion. const m = /^(\$?)([A-Za-z]{1,3})(\$?)(\d+)$/.exec(String(ref || "")); if (!m) return null; let col = colToIndex(m[2]); let row = Number(m[4]); if (!col || !(row >= 1)) return null; if (op.axis === "row") { if (m[3]) return `${m[1]}${m[2]}${m[3]}${row}`; if (op.insert) { if (row >= op.at) row += op.count; } else if (row >= op.at && row < op.at + op.count) { return null; } else if (row >= op.at + op.count) { row -= op.count; } } else { if (m[1]) return `${m[1]}${m[2]}${m[3]}${row}`; if (op.insert) { if (col >= op.at) col += op.count; } else if (col >= op.at && col < op.at + op.count) { return null; } else if (col >= op.at + op.count) { col -= op.count; } } if (row < 1 || col < 1) return null; return `${m[1]}${indexToCol(col)}${m[3]}${row}`; }; const shiftRangeStr = (range, op) => { // "A1:B2" → shifted range, or null when destroyed by a deletion. const m = /^([^:]+):([^:]+)$/.exec(String(range || "")); if (!m) return shiftSingleCoord(range, op); const a = shiftSingleCoord(m[1], op); const b = shiftSingleCoord(m[2], op); if (!a || !b) return null; // Normalize order (insert order is preserved, deletes clamp it). const pa = parseRef(a); const pb = parseRef(b); if (!pa || !pb) return null; const r1 = Math.min(pa.row, pb.row); const r2 = Math.max(pa.row, pb.row); const c1 = Math.min(pa.col, pb.col); const c2 = Math.max(pa.col, pb.col); return `${indexToCol(c1)}${r1}:${indexToCol(c2)}${r2}`; }; const shiftFormulaRefs = (text, selfSheet, op) => { // Shift the A1 references of a formula like Excel would, skipping string // literals, $ locked parts and other sheets' qualifiers. Ranges whose end // dies in a deletion collapse to #REF!. const parts = String(text || "").split(/("[^"]*")/g); for (let i = 0; i < parts.length; i += 2) { parts[i] = parts[i].replace( /((?:'[^']+'|[A-Za-z_][\w.]*)!)?(\$?)([A-Za-z]{1,3})(\$?)(\d+)(?!\s*\()/g, (match, qualifier, colAbs, letters, rowAbs, rowNum) => { if (qualifier) { const q = qualifier.slice(0, -1).replace(/^'|'$/g, ""); if (q.toLowerCase() !== String(selfSheet).toLowerCase()) return match; } const shifted = shiftSingleCoord(`${colAbs}${letters}${rowAbs}${rowNum}`, op); if (!shifted) return "#REF!"; return `${qualifier || ""}${shifted}`; }, ); } let out = parts.join(""); out = out.replace(/([A-Za-z$0-9]+:#REF!|#REF!:[A-Za-z$0-9]+)/g, "#REF!"); return out; }; const adjustFormulaTexts = (sheetName, op) => { panelEls.forEach((panel) => { panel.querySelectorAll("td[data-cell]").forEach((td) => { if (!td.dataset.formula) return; const next = shiftFormulaRefs(td.dataset.formula, sheetName, op); if (next !== td.dataset.formula) { td.dataset.formula = next; delete td.dataset.display; } }); }); }; const shiftMetaForOp = (sheetIdx, op) => { const meta = sheets[sheetIdx]; if (!meta) return; const shiftKey = (ref) => { const p = parseRef(ref); if (!p) return null; if (op.axis === "row") { if (op.insert) return p.row >= op.at ? `${indexToCol(p.col)}${p.row + op.count}` : ref; if (p.row >= op.at && p.row < op.at + op.count) return null; return p.row >= op.at + op.count ? `${indexToCol(p.col)}${p.row - op.count}` : ref; } if (op.insert) return p.col >= op.at ? `${indexToCol(p.col + op.count)}${p.row}` : ref; if (p.col >= op.at && p.col < op.at + op.count) return null; return p.col >= op.at + op.count ? `${indexToCol(p.col - op.count)}${p.row}` : ref; }; ["styles", "aligns", "comments", "formats"].forEach((key) => { const map = meta[key]; if (!map) return; Object.keys(map).forEach((ref) => { const next = shiftKey(ref); if (next === ref || next === null) { if (next === null) delete map[ref]; return; } const value = map[ref]; delete map[ref]; map[next] = value; }); }); if (op.axis === "col" && meta.colwidths) { Object.keys(meta.colwidths).forEach((letter) => { const c = colToIndex(letter); if (!c) return; const value = meta.colwidths[letter]; delete meta.colwidths[letter]; const shifted = shiftSingleCoord(`${letter}1`, op); if (shifted) meta.colwidths[shifted.replace(/[0-9]+$/, "")] = value; }); } if (op.axis === "row" && meta.rowheights) { Object.keys(meta.rowheights).forEach((r) => { const row = Number(r); if (!(row >= 1)) return; const value = meta.rowheights[r]; delete meta.rowheights[r]; const shifted = shiftSingleCoord(`A${row}`, op); if (shifted) meta.rowheights[String(parseRef(shifted).row)] = value; }); } if (meta.merges) { meta.merges = meta.merges .map((range) => shiftRangeStr(range, op)) .filter(Boolean); } if (meta.freeze && meta.freeze !== "A1") { const shifted = shiftSingleCoord(meta.freeze, op); meta.freeze = shifted || "A1"; } }; const refreshTruncationNote = (panel) => { const meta = sheets[Number(panel.dataset.sheet)] || {}; const html = truncationNote(meta); const old = panel.querySelector(".xlsx-truncated"); if (html) { if (old) { old.outerHTML = html; } else { const tmp = document.createElement("div"); tmp.innerHTML = html; const node = tmp.firstElementChild; if (node) { const wrap = panel.querySelector(".csv-table-wrapper"); if (wrap) wrap.after(node); else panel.appendChild(node); } } safeCreateIcons(); } else if (old) { old.remove(); } }; /** Shift a selection anchor through a structural op (delete-span collapses). */ const shiftSelPoint = (pt, op) => { if (!pt) return null; const num = op.axis === "row" ? pt.row : pt.col; const fixed = op.axis === "row" ? pt.col : pt.row; if (num === undefined || fixed === undefined) return null; let n = num; if (op.insert) { if (n >= op.at) n += op.count; } else if (n >= op.at && n < op.at + op.count) { n = op.at; } else if (n >= op.at + op.count) { n -= op.count; } if (n < 1) n = 1; return op.axis === "row" ? { row: n, col: fixed } : { row: fixed, col: n }; }; const blankTd = (ref) => { const td = document.createElement("td"); td.dataset.cell = ref; td.setAttribute("role", "gridcell"); setupCell(td); return td; }; const applyRowColOp = (sheetIdx, op) => { const panel = panelEls.find((p) => Number(p.dataset.sheet) === sheetIdx); const meta = sheets[sheetIdx]; if (!panel || !meta) return; const tbody = panel.querySelector(".xlsx-table tbody"); const headRow = panel.querySelector(".xlsx-table thead tr"); const nCols = headRow ? headRow.querySelectorAll("th:not(.xlsx-corner)").length : 0; const rendered = tbody ? tbody.querySelectorAll("tr").length : 0; adjustFormulaTexts(meta.name, op); shiftMetaForOp(sheetIdx, op); const totalKey = op.axis === "row" ? "total_rows" : "total_cols"; meta[totalKey] = Math.max(0, (Number(meta[totalKey]) || 0) + (op.insert ? op.count : -op.count)); if (op.axis === "row" && op.insert && tbody && op.at <= rendered + 1 && nCols > 0) { const pos = Math.min(op.at, rendered + 1); const anchor = [...tbody.children].find((tr) => rowNumOf(tr) === pos) || null; for (let k = 0; k < op.count; k++) { const tr = document.createElement("tr"); tr.setAttribute("role", "row"); tr.dataset.fresh = "1"; const th = document.createElement("th"); th.className = "xlsx-rownum"; th.setAttribute("role", "rowheader"); th.textContent = String(op.at + k); th.style.cursor = "pointer"; th.title = t("xlsx.select_row"); tr.appendChild(th); for (let c = 1; c <= nCols; c++) { const td = blankTd(`${columnName(c)}${op.at + k}`); td.setAttribute("role", "gridcell"); tr.appendChild(td); } tbody.insertBefore(tr, anchor); } // Shift existing rows by reference (DOM order may differ after a sort, // so recounting positions would corrupt references). tbody.querySelectorAll("tr:not([data-fresh])").forEach((tr) => { const rn = rowNumOf(tr); if (!(rn >= op.at)) return; const next = rn + op.count; const th = tr.querySelector("th.xlsx-rownum"); if (th) th.textContent = String(next); tr.querySelectorAll("td[data-cell]").forEach((td) => { const p = parseRef(td.dataset.cell); if (p && p.row >= op.at) td.dataset.cell = `${columnName(p.col)}${p.row + op.count}`; }); }); tbody.querySelectorAll("tr[data-fresh]").forEach((tr) => { delete tr.dataset.fresh; }); } else if (op.axis === "row" && !op.insert && tbody) { [...tbody.children].forEach((tr) => { const rn = rowNumOf(tr); if (rn >= op.at && rn < op.at + op.count) tr.remove(); }); tbody.querySelectorAll("tr").forEach((tr) => { const rn = rowNumOf(tr); if (!(rn >= 1)) return; if (rn >= op.at + op.count) { const next = rn - op.count; const th = tr.querySelector("th.xlsx-rownum"); if (th) th.textContent = String(next); tr.querySelectorAll("td[data-cell]").forEach((td) => { const p = parseRef(td.dataset.cell); if (p && p.row >= op.at + op.count) td.dataset.cell = `${columnName(p.col)}${p.row - op.count}`; }); } }); } else if (op.axis === "col" && op.insert && tbody && nCols > 0 && op.at <= nCols + 1) { for (let k = 0; k < op.count; k++) { const th = document.createElement("th"); th.textContent = columnName(op.at + k); th.style.cursor = "pointer"; th.title = t("xlsx.select_column"); th.dataset.fresh = "1"; headRow.insertBefore(th, headRow.children[op.at + k] || null); } tbody.querySelectorAll("tr").forEach((tr) => { const rn = rowNumOf(tr); for (let k = 0; k < op.count; k++) { const td = blankTd(`${columnName(op.at + k)}${rn}`); td.setAttribute("role", "gridcell"); td.dataset.fresh = "1"; const after = [...tr.querySelectorAll("td[data-cell]")].find((cell) => { const p = parseRef(cell.dataset.cell); return p && p.col >= op.at && !cell.dataset.fresh; }); tr.insertBefore(td, after || null); } }); // Shift existing references by value (never by position: sorts reorder). headRow.querySelectorAll("th:not(.xlsx-corner):not([data-fresh])").forEach((th) => { const idx = colToIndex(th.textContent.trim()); if (idx >= op.at) th.textContent = columnName(idx + op.count); }); tbody.querySelectorAll("td[data-cell]:not([data-fresh])").forEach((td) => { const p = parseRef(td.dataset.cell); if (p && p.col >= op.at) td.dataset.cell = `${columnName(p.col + op.count)}${p.row}`; }); panel.querySelectorAll("[data-fresh]").forEach((el) => { delete el.dataset.fresh; }); } else if (op.axis === "col" && !op.insert && tbody && nCols > 0) { const kill = new Set(); for (let c = op.at; c < op.at + op.count; c++) kill.add(c); [...headRow.children].forEach((th, i) => { if (i > 0) { const idx = colToIndex(th.textContent.trim()); if (kill.has(idx)) th.remove(); } }); tbody.querySelectorAll("tr").forEach((tr) => { [...tr.querySelectorAll("td[data-cell]")].forEach((td) => { const p = parseRef(td.dataset.cell); if (!p) return; if (kill.has(p.col)) td.remove(); }); }); headRow.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => { const idx = colToIndex(th.textContent.trim()); if (idx >= op.at + op.count) th.textContent = columnName(idx - op.count); }); tbody.querySelectorAll("td[data-cell]").forEach((td) => { const p = parseRef(td.dataset.cell); if (p && p.col >= op.at + op.count) td.dataset.cell = `${columnName(p.col - op.count)}${p.row}`; }); } // Rebuild spans/freeze/dims from the shifted meta, recompute, tidy UI. invalidateCellIndex(panel); applySheetMeta(panel); applyGridA11y(panel); refreshTruncationNote(panel); refreshPanelFormulas(panel); // The active cell may have been renumbered or removed: re-resolve it. if (activeTd && !activeTd.isConnected) { const p = parseRef(cellName(activeTd)); let next = null; if (p) { const s = shiftSelPoint({ row: p.row, col: p.col }, op); if (s) next = findTd(panel, s.row, s.col); } setActiveCell(next); } if (selection && selection.panel === panel) { const a = shiftSelPoint(selection.anchor, op); const f = shiftSelPoint(selection.focus, op); if (a && f) { const aTd = findTd(panel, a.row, a.col); const fTd = findTd(panel, f.row, f.col); if (aTd && fTd) { selection.anchor = a; selection.focus = f; applySelection(); } else { setSelection(null); } } else { setSelection(null); } if (!selection) { setActiveCell(null); const bar = area.querySelector("#xlsx-minibar"); if (bar) bar.hidden = true; } } updateSheetsToolbarStates(); refreshSaveState(); }; /** Activate a sheet tab by index (tab strip + panels + states). */ const activateSheetTab = (idx) => { area.querySelectorAll(".xlsx-tab").forEach((x) => { const on = Number(x.dataset.sheet) === idx; x.classList.toggle("active", on); x.setAttribute("aria-selected", String(on)); }); panelEls.forEach((p) => { p.style.display = Number(p.dataset.sheet) === idx ? "" : "none"; }); // #184 — pad the sheet that just came on screen (A → Z, 1000 rows). ensureGridPadded(visiblePanel()); refreshPanelFormulas(visiblePanel()); updateSheetsToolbarStates(); updateMinibar(); requestStick(); }; const reindexSheets = () => { panelEls.forEach((p, i) => { p.dataset.sheet = String(i); }); area.querySelectorAll(".xlsx-tab").forEach((x, i) => { x.dataset.sheet = String(i); }); }; const blankPanelHtml = (cols, rows) => { let thead = ''; for (let c = 1; c <= cols; c++) thead += ``; thead += ""; let body = ""; for (let r = 1; r <= rows; r++) { body += ``; for (let c = 1; c <= cols; c++) body += ``; body += ""; } return `
${columnName(c)}
${r}
${thead}${body}
`; }; const wireFreshPanel = (panel) => { panel.querySelectorAll(".xlsx-table td").forEach(setupCell); wireResize(panel); wireLazyRows(panel); applySheetMeta(panel); applyGridA11y(panel); }; const addSheetLocal = (name) => { const clean = String(name || "").slice(0, 31); if (!clean || sheets.some((s) => s && s.name === clean)) return; // Same blank shape as the server (BUG-094): 8 cols × 20 rows. const idx = sheets.length; sheets.push({ name: clean, html: "", rows: 20, cols: 8, total_rows: 20, total_cols: 8, max_rows: 500, max_cols: 40, truncated: false, styles: {}, aligns: {}, comments: {}, formats: {}, colwidths: {}, rowheights: {}, merges: [], freeze: "", }); const tabs = area.querySelector(".xlsx-tabs"); if (tabs) { const tab = document.createElement("button"); tab.className = "xlsx-tab"; tab.dataset.sheet = String(idx); tab.setAttribute("role", "tab"); tab.setAttribute("aria-selected", "false"); tab.textContent = clean; tab.addEventListener("click", () => activateSheetTab(idx)); const addBtn = tabs.querySelector("#xlsx-tab-add"); tabs.insertBefore(tab, addBtn || null); } const host = area.querySelector(".xlsx-panels"); if (!host) return; // CSV mode has no sheet tabs const panel = document.createElement("div"); panel.className = "xlsx-panel"; panel.dataset.sheet = String(idx); panel.style.display = "none"; panel.innerHTML = blankPanelHtml(8, 20); host.appendChild(panel); panelEls.push(panel); wireFreshPanel(panel); activateSheetTab(idx); setActiveCell(panel.querySelector('td[data-cell="A1"]')); }; const renameSheetLocal = (from, to) => { const clean = String(to || "").slice(0, 31); const idx = sheets.findIndex((s) => s && s.name === from); if (idx < 0 || !clean || clean === from) return; sheets[idx].name = clean; const tab = area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`); if (tab) tab.textContent = clean; [undoStack, redoStack].forEach((stack) => { stack.forEach((entry) => { if (entry && entry.sheet === from) entry.sheet = clean; }); }); // Cross-sheet qualifiers ('Old'!A1) follow the rename like Excel — // outside string literals, which keep their text. const esc = String(from).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const both = new RegExp(`(^|[^\\w.$])((?:'${esc}'|${esc})!)`, "g"); const renameInText = (text) => text.split(/("[^"]*")/g).map((part, i) => { if (i % 2 === 1) return part; return part.replace(both, (m, pre, q) => { const quoted = q.startsWith("'"); const needQuotes = /[^A-Za-z0-9_.$]/.test(clean); const name = needQuotes || quoted ? `'${clean.replace(/'/g, "''")}'` : clean; return `${pre}${name}!`; }); }).join(""); panelEls.forEach((panel) => { panel.querySelectorAll("td[data-cell]").forEach((td) => { if (!td.dataset.formula) return; const next = renameInText(td.dataset.formula); if (next !== td.dataset.formula) { td.dataset.formula = next; delete td.dataset.display; } }); }); }; const deleteSheetLocal = (name) => { const idx = sheets.findIndex((s) => s && s.name === name); if (idx < 0 || sheets.length <= 1) return; area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`)?.remove(); const panel = panelEls[idx]; if (panel) panel.remove(); sheets.splice(idx, 1); panelEls.splice(idx, 1); reindexSheets(); [undoStack, redoStack].forEach((stack) => { for (let i = stack.length - 1; i >= 0; i--) { if (stack[i] && stack[i].sheet === name) stack.splice(i, 1); } }); updateUndoButtons(); if (activeTd && !activeTd.isConnected) setActiveCell(null); if (selection && !selection.panel.isConnected) { selection = null; const bar = area.querySelector("#xlsx-minibar"); if (bar) bar.hidden = true; } activateSheetTab(0); }; const duplicateSheetLocal = (name, as) => { const clean = String(as || "").slice(0, 31); const srcIdx = sheets.findIndex((s) => s && s.name === name); if (srcIdx < 0 || !clean || sheets.some((s) => s && s.name === clean)) return; const srcPanel = panelEls.find((p) => Number(p.dataset.sheet) === srcIdx); if (!srcPanel) return; const idx = sheets.length; const meta = JSON.parse(JSON.stringify(sheets[srcIdx] || {})); meta.name = clean; sheets.push(meta); const tabs = area.querySelector(".xlsx-tabs"); if (tabs) { const tab = document.createElement("button"); tab.className = "xlsx-tab"; tab.dataset.sheet = String(idx); tab.setAttribute("role", "tab"); tab.setAttribute("aria-selected", "false"); tab.textContent = clean; tab.addEventListener("click", () => activateSheetTab(idx)); const addBtn = tabs.querySelector("#xlsx-tab-add"); tabs.insertBefore(tab, addBtn || null); } const clone = srcPanel.cloneNode(true); clone.dataset.sheet = String(idx); clone.style.display = "none"; clone.querySelector("table")?.removeAttribute("data-select-wired"); clone.querySelector("table")?.removeAttribute("data-resize-wired"); clone.querySelector(".xlsx-load-more")?.remove(); clone.querySelectorAll("td.xlsx-dirty").forEach((td) => { td.classList.remove("xlsx-dirty"); td.dataset.orig = td.textContent; }); clone.querySelectorAll("td.xlsx-active").forEach((td) => td.classList.remove("xlsx-active")); clone.querySelectorAll("td.xlsx-selected").forEach((td) => td.classList.remove("xlsx-selected")); const host = area.querySelector(".xlsx-panels"); if (!host) return; host.appendChild(clone); panelEls.push(clone); wireFreshPanel(clone); activateSheetTab(idx); setActiveCell(clone.querySelector('td[data-cell="A1"]')); }; const applyStructureLocal = (actions) => { (actions || []).forEach((action) => { if (!action || typeof action !== "object") return; const op = action.op; if (op === "row_insert" || op === "row_delete" || op === "col_insert" || op === "col_delete") { const idx = sheets.findIndex((s) => s && s.name === action.sheet); if (idx < 0) return; const at = Number(action.at) || 1; const count = Math.max(1, Number(action.count) || 1); applyRowColOp(idx, { axis: op.startsWith("row") ? "row" : "col", at, count, insert: op.endsWith("insert"), }); } else if (op === "sheet_add") { addSheetLocal(action.name); } else if (op === "sheet_rename") { renameSheetLocal(action.from, action.to); } else if (op === "sheet_delete") { deleteSheetLocal(action.name); } else if (op === "sheet_duplicate") { duplicateSheetLocal(action.name, action.as); } }); refreshAllFormulas(); updateSheetsToolbarStates(); refreshSaveState(); }; const putStructure = async (actions, force = false, { quiet = false, record = true } = {}) => { window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() }; const res = 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 }), }, ); keepRevision(res); 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 }); } } // Dynamic: replay the batch in the DOM — grid, focus and selection // survive (no full re-render). applyStructureLocal(actions); return res; }; 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.) // Anchor a toolbar menu under its toggle button (clamped to the toolbar // horizontally; dropped above the button when it would overflow the viewport). const anchorUnder = (menu, toolbar, btn) => { const toolRect = toolbar.getBoundingClientRect(); const btnRect = btn.getBoundingClientRect(); const vh = (typeof window !== "undefined" && window.innerHeight) || 768; const w = menu.offsetWidth || 240; const h = menu.offsetHeight || 0; // With zeroed rects (JSDOM) this settles on 4px/4px — still assertable. const left = Math.max(4, Math.min(btnRect.left - toolRect.left, Math.max(4, toolRect.width - w - 4))); let top = btnRect.bottom - toolRect.top + 4; if (h && toolRect.top + top + h > vh - 8) { top = Math.max(4, btnRect.top - toolRect.top - h - 4); } menu.style.left = `${Math.round(left)}px`; menu.style.top = `${Math.round(top)}px`; }; // #179 — tracked dismissal of the structure menu (outside, focus loss…). let dismissStructureMenu = null; const structureBtn = area.querySelector("#xlsx-structure-btn"); if (structureBtn) structureBtn.addEventListener("click", (e) => { if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; 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"; menu.setAttribute("role", "menu"); // #179 — representative icon per entry; destructive ones read as danger. const item = (iconName, label, fn, danger = false) => { const b = document.createElement("button"); b.type = "button"; b.className = `btn-action xlsx-structure-item${danger ? " xlsx-context-danger" : ""}`; b.setAttribute("role", "menuitem"); if (iconName) { const ic = document.createElement("i"); ic.setAttribute("data-lucide", iconName); ic.className = "xlsx-menu-icon"; ic.setAttribute("aria-hidden", "true"); b.appendChild(ic); } const lab = document.createElement("span"); lab.className = "xlsx-menu-label"; lab.textContent = label; b.appendChild(lab); b.addEventListener("click", () => { if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; } fn(); }); menu.appendChild(b); }; item("file-plus", 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("pencil", 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("copy", 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("trash-2", 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); } }, true); if (parsed) { menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" })); item("rows", 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("trash-2", 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); } }, true); item("columns", 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("trash-2", 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); } }, true); } enableArrowNav(menu, ".xlsx-structure-item"); const structureToolbar = e.target.closest(".xlsx-toolbar"); structureToolbar.appendChild(menu); anchorUnder(menu, structureToolbar, structureBtn); safeCreateIcons(); structureBtn.setAttribute("aria-expanded", "true"); // #179 — close on outside click, focus loss, Escape, scroll, resize. dismissStructureMenu = trackDismissable(menu, { toggle: structureBtn, onClose: () => { dismissStructureMenu = null; structureBtn.setAttribute("aria-expanded", "false"); menu.remove(); }, }); }); // ── #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. */ // #184 — the grid is laid out generously (columns A → Z, rows grown on // demand), so the tail of empty rows/columns is LAYOUT, not data: the export // carries the used block, the way a spreadsheet export does. Without this a // two-column sheet exported 26 columns of blanks. const isBlankCell = (v) => String(v == null ? "" : v).trim() === ""; const sheetMatrix = (panel) => { const rows = [...panel.querySelectorAll(".xlsx-table tbody tr")].map((tr) => [...tr.querySelectorAll("td")].map((td) => cellTextOf(td)), ); while (rows.length && rows[rows.length - 1].every(isBlankCell)) rows.pop(); let width = 0; rows.forEach((r) => { for (let c = r.length - 1; c >= 0; c--) { if (!isBlankCell(r[c])) { if (c + 1 > width) width = c + 1; break; } } }); return rows.map((r) => r.slice(0, width)); }; /** 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) => ` ${escapeHtml(title)} ${rows .map((r) => `${r.map((v) => ``).join("")}`) .join("")}
${escapeHtml(v)}
`; const exportSheetCsv = () => { 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"); } }; // #185 — the file menu moved to the menu bar (frontend/js/xlsx/menus.js); // its `[data-xlsx-export]` entries and `#xlsx-download-btn` / // `#xlsx-csv-btn` ids are wired by the generic menu handler below. // ── #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"; }; // ── Dynamic style application (no re-render) ────────────────────────── // Mirrors the server fragment mapping so the grid matches the file without // a round-trip: patch the sheet meta maps, then reuse applySheetMeta(). const splitDecls = (css) => { const parts = []; String(css || "").split(";").forEach((p) => { const i = p.indexOf(":"); if (i > 0) parts.push([p.slice(0, i).trim(), p.slice(i + 1).trim()]); }); return parts; }; const setDecl = (parts, key, value) => { const i = parts.findIndex((p) => p[0] === key); if (value === null || value === undefined || value === "") { if (i >= 0) parts.splice(i, 1); } else if (i >= 0) { parts[i][1] = value; } else { parts.push([key, value]); } }; const normHex = (v) => { const s = String(v || "").trim().replace(/^#/, ""); return /^[0-9a-fA-F]{6}$/.test(s) ? `#${s.toLowerCase()}` : null; }; const patchCellFragment = (meta, ref, style) => { const styles = meta.styles || (meta.styles = {}); const aligns = meta.aligns || (meta.aligns = {}); const formats = meta.formats || (meta.formats = {}); const parts = splitDecls(styles[ref] || ""); if ("bold" in style) setDecl(parts, "font-weight", style.bold ? "600" : null); if ("italic" in style) setDecl(parts, "font-style", style.italic ? "italic" : null); if ("underline" in style || "strike" in style) { const cur = new Set((((parts.find((p) => p[0] === "text-decoration") || [])[1]) || "").split(" ").filter(Boolean)); // Baseline from the td when the map has no entry yet (fresh styling). if ("underline" in style) { if (style.underline) cur.add("underline"); else cur.delete("underline"); } if ("strike" in style) { if (style.strike) cur.add("line-through"); else cur.delete("line-through"); } setDecl(parts, "text-decoration", cur.size ? [...cur].join(" ") : null); } if ("font_color" in style) { const hex = style.font_color ? normHex(style.font_color) : null; setDecl(parts, "color", hex); } if ("fill_color" in style) { const hex = style.fill_color ? normHex(style.fill_color) : null; setDecl(parts, "background", hex); } if ("font_size" in style) { const n = Number(style.font_size); setDecl(parts, "font-size", Number.isFinite(n) && n !== 11 ? `${Number.isInteger(n) ? n : Math.round(n * 100) / 100}pt` : null); } if ("align" in style) { if (style.align === "left") delete aligns[ref]; else aligns[ref] = style.align; } if ("valign" in style) { setDecl(parts, "vertical-align", style.valign === "middle" ? "middle" : style.valign); } if ("wrap" in style) setDecl(parts, "white-space", style.wrap ? "normal" : "nowrap"); if ("rotation" in style) { const r = Number(style.rotation) || 0; if (r === 90) { setDecl(parts, "writing-mode", "vertical-rl"); setDecl(parts, "transform", null); } else if (r === 180) { setDecl(parts, "writing-mode", "vertical-rl"); setDecl(parts, "transform", "scale(-1)"); } else if (r) { // Diagonal (ex. ±45° du menu Rotation) : le CSS tourne en sens // horaire, Excel en anti-horaire — signe inversé, comme le lecteur. setDecl(parts, "writing-mode", null); setDecl(parts, "transform", `rotate(${-r}deg)`); } else { setDecl(parts, "writing-mode", null); setDecl(parts, "transform", null); } } if ("border" in style || "border_style" in style || "border_color" in style) { const which = style.border || "all"; if (which === "none") { ["border-left", "border-right", "border-top", "border-bottom"].forEach((k) => setDecl(parts, k, null)); } else { const st = { medium: "2px", thick: "3px" }[style.border_style] || "1px"; const bs = ["dashed", "dotted", "double"].includes(style.border_style) ? style.border_style : "solid"; const hex = normHex(style.border_color) || "#000000"; ["border-left", "border-right", "border-top", "border-bottom"].forEach((k) => { setDecl(parts, k, `${st} ${bs} ${hex}`); }); } } if ("number_format" in style) { const fmt = style.number_format || "General"; if (fmt === "General" || fmt === "@") delete formats[ref]; else formats[ref] = fmt; setDecl(parts, "font-family", (fmt === "General" || fmt === "@") ? null : "'JetBrains Mono',monospace"); } const css = parts.map(([k, v]) => `${k}:${v}`).join(";"); if (css) styles[ref] = css; else delete styles[ref]; }; const expandRangeRefs = (range) => { const m = /^\$?([A-Za-z]{1,3})\$?(\d+)(?::\$?([A-Za-z]{1,3})\$?(\d+))?$/.exec(String(range || "").trim()); if (!m) return []; const c1 = colToIndex(m[1]); const r1 = Number(m[2]); const c2 = m[3] ? colToIndex(m[3]) : c1; const r2 = m[4] ? Number(m[4]) : r1; if (!c1 || !c2 || !(r1 >= 1) || !(r2 >= 1)) return []; const out = []; for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) { for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) { out.push(`${indexToCol(c)}${r}`); if (out.length >= 10000) return out; } } return out; }; /** Patch the sheet meta maps from style ops, then repaint (no re-render). */ const applyStyleOpsLocal = (sheetIdx, ops) => { const meta = sheets[sheetIdx]; if (!meta) return; const panel = panelEls.find((p) => Number(p.dataset.sheet) === sheetIdx); ops.forEach((op) => { if (!op || typeof op !== "object") return; if (op.op === "cell") { const refs = expandRangeRefs(op.range || op.cell || ""); refs.forEach((ref) => patchCellFragment(meta, ref, op.style || {})); } else if (op.op === "merge") { meta.merges = meta.merges || []; if (op.range && !meta.merges.includes(op.range)) meta.merges.push(op.range); } else if (op.op === "unmerge") { meta.merges = (meta.merges || []).filter((m) => m !== op.range); } else if (op.op === "col_width") { const col = String(op.col || "").trim().toUpperCase(); meta.colwidths = meta.colwidths || {}; if (col) meta.colwidths[col] = Number(op.width); } else if (op.op === "row_height") { const row = Number(op.row); meta.rowheights = meta.rowheights || {}; if (row >= 1) meta.rowheights[String(row)] = Number(op.height); } else if (op.op === "freeze") { meta.freeze = op.cell ? String(op.cell).toUpperCase() : ""; } else if (op.op === "comment_set") { meta.comments = meta.comments || {}; if (op.ref && op.text) meta.comments[String(op.ref).toUpperCase()] = String(op.text).slice(0, 500); } else if (op.op === "comment_clear") { if (meta.comments) delete meta.comments[String(op.ref || "").toUpperCase()]; } }); if (panel) { applySheetMeta(panel); refreshPanelFormulas(panel); } updateSheetsToolbarStates(); }; const putStyle = async (ops, force = false) => { window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() }; 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 }), }, ); keepRevision(res); showToast(t("xlsx.format_applied"), "success"); // Dynamic: patch the meta maps and repaint in place — the grid, focus // and selection survive (no full re-render). const bySheet = new Map(); ops.forEach((op) => { if (!op || typeof op !== "object") return; const idx = sheets.findIndex((s) => s && s.name === op.sheet); if (idx < 0) return; if (!bySheet.has(idx)) bySheet.set(idx, []); bySheet.get(idx).push(op); }); bySheet.forEach((sheetOps, idx) => applyStyleOpsLocal(idx, sheetOps)); return res; }; 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 || "").includes("underline")); }; const hasStrike = () => { const td = activeStyleCell(); return Boolean(td && (td.style.textDecoration || "").includes("line-through")); }; // #179 — tracked dismissal of the format menu (outside, focus loss…). let dismissFormatMenu = null; const formatBtn = area.querySelector("#xlsx-format-btn"); if (formatBtn) formatBtn.addEventListener("click", () => { if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; return; } const sheetName = sheets[visibleSheetIndex()]?.name || ""; const menu = document.createElement("div"); menu.className = "xlsx-structure-menu xlsx-format-menu"; menu.setAttribute("role", "menu"); // #179 — representative icon per entry. const item = (iconName, label, fn) => { const b = document.createElement("button"); b.type = "button"; b.className = "btn-action xlsx-structure-item"; b.setAttribute("role", "menuitem"); if (iconName) { const ic = document.createElement("i"); ic.setAttribute("data-lucide", iconName); ic.className = "xlsx-menu-icon"; ic.setAttribute("aria-hidden", "true"); b.appendChild(ic); } const lab = document.createElement("span"); lab.className = "xlsx-menu-label"; lab.textContent = label; b.appendChild(lab); b.addEventListener("click", () => { if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; } 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("bold", t("xlsx.format_bold"), cellStyle({ bold: !hasBold() })); item("italic", t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() })); item("underline", t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() })); item("strikethrough", t("xlsx.format_strike"), cellStyle({ strike: !hasStrike() })); item("remove-formatting", t("xlsx.format_clear"), cellStyle({ bold: false, italic: false, underline: false, font_color: "", fill_color: "", align: "left", number_format: "General", })); sep(); item("align-left", t("xlsx.format_align_left"), cellStyle({ align: "left" })); item("align-center", t("xlsx.format_align_center"), cellStyle({ align: "center" })); item("align-right", t("xlsx.format_align_right"), cellStyle({ align: "right" })); sep(); // #183 — colours open the same Sheets-like panel as the ribbon (the native // input is only driven by its « + » / crayon buttons). const colour = (iconName, label, kind) => { const row = document.createElement("div"); row.className = "xlsx-format-colour"; if (iconName) { const ic = document.createElement("i"); ic.setAttribute("data-lucide", iconName); ic.className = "xlsx-menu-icon"; ic.setAttribute("aria-hidden", "true"); row.appendChild(ic); } const span = document.createElement("span"); span.textContent = label; row.appendChild(span); const trigger = document.createElement("button"); trigger.type = "button"; trigger.className = "xlsx-format-colour-btn"; trigger.dataset.colourMenu = kind; trigger.title = label; trigger.setAttribute("aria-label", label); trigger.setAttribute("aria-haspopup", "dialog"); const dot = document.createElement("span"); dot.className = "xlsx-format-colour-dot"; dot.dataset.for = kind; dot.setAttribute("aria-hidden", "true"); trigger.appendChild(dot); row.appendChild(trigger); menu.appendChild(row); return trigger; }; colour("palette", t("xlsx.format_font_color"), "font"); colour("paint-bucket", t("xlsx.format_fill_color"), "fill"); // #183 — the two colour rows open the same Sheets-like panel as the ribbon. const openMenuColourPanel = (btn) => { const kind = btn.dataset.colourMenu === "fill" ? "fill" : "font"; const td = activeStyleCell(); const value = normHex(kind === "fill" ? (td ? cssToHex(td.style.backgroundColor || td.style.background) : null) : (td ? cssToHex(td.style.color) : null)) || ""; // Never two panels at once (the ribbon one may still be open). area.querySelectorAll(".xlsx-colour-panel[data-xlsx-colour-panel]").forEach((p) => p.remove()); const { el, sync } = createColourPicker({ kind, value, custom: value || "", onPick: (colour) => { remove(); runStyle([{ op: "cell", sheet: sheetName, range: selectionRange(), style: kind === "fill" ? { fill_color: colour } : { font_color: colour }, }]); }, onReset: () => { remove(); runStyle([{ op: "cell", sheet: sheetName, range: selectionRange(), style: kind === "fill" ? { fill_color: "" } : { font_color: "" }, }]); }, onCustom: () => { // Arbitrary colours keep using the native picker: the panel offers // the 80 curated swatches + the 8 standard ones. const native = document.createElement("input"); native.type = "color"; native.value = value || "#000000"; native.setAttribute("data-xlsx-native", kind); native.style.cssText = "position:absolute;opacity:0;pointer-events:none"; native.addEventListener("change", () => { const picked = native.value; const dot = menu.querySelector(`.xlsx-format-colour-dot[data-for="${kind}"]`); if (dot) dot.style.background = picked; native.remove(); remove(); runStyle([{ op: "cell", sheet: sheetName, range: selectionRange(), style: kind === "fill" ? { fill_color: picked } : { font_color: picked }, }]); }); menu.appendChild(native); if (typeof native.click === "function") native.click(); }, onUnavailable: (label) => showToast(t("xlsx.colour_unavailable", { feature: label }), "info"), }); const remove = () => el.remove(); menu.appendChild(el); const dot = menu.querySelector(`.xlsx-format-colour-dot[data-for="${kind}"]`); if (dot && value) dot.style.background = value; safeCreateIcons(); sync(value); }; menu.querySelectorAll("[data-colour-menu]").forEach((trigger) => { trigger.addEventListener("click", (e) => { e.preventDefault(); openMenuColourPanel(trigger); }); }); sep(); item("type", t("xlsx.format_num_general"), numFormat("General")); item("hash", t("xlsx.format_num_number"), numFormat("0.00")); item("percent", t("xlsx.format_num_percent"), numFormat("0.0%")); item("euro", t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"')); item("calendar", t("xlsx.format_num_date"), numFormat("DD/MM/YYYY")); item("pilcrow", t("xlsx.format_num_text"), numFormat("@")); sep(); item("combine", 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("ungroup", t("xlsx.format_unmerge"), () => { runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]); }); sep(); item("snowflake", t("xlsx.format_freeze"), () => { runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]); }); item("sun", t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }])); sep(); item("move-horizontal", t("xlsx.format_col_width"), () => promptColumnWidth()); item("maximize-2", t("xlsx.format_col_fit"), () => autoFitSelection("col")); item("move-vertical", t("xlsx.format_row_height"), () => promptRowHeight()); item("maximize-2", t("xlsx.format_row_fit"), () => autoFitSelection("row")); enableArrowNav(menu, ".xlsx-structure-item"); const formatToolbar = formatBtn.closest(".xlsx-toolbar"); formatToolbar.appendChild(menu); anchorUnder(menu, formatToolbar, formatBtn); safeCreateIcons(); formatBtn.setAttribute("aria-expanded", "true"); // #179 — close on outside click, focus loss, Escape, scroll, resize. dismissFormatMenu = trackDismissable(menu, { toggle: formatBtn, onClose: () => { dismissFormatMenu = null; formatBtn.setAttribute("aria-expanded", "false"); menu.remove(); }, }); }); // ══ Sheets-like ribbon wiring ────────────────────────────────────────── // Every control below reuses the locked PUT …/xlsx/style rewrite (runStyle) // or existing viewer flows — no new backend route. const styleSheetName = () => sheets[visibleSheetIndex()]?.name || ""; const runCellStyle = (style) => runStyle([ { op: "cell", sheet: styleSheetName(), range: selectionRange(), style }, ]); const sheetFormatsOf = () => { const meta = sheets[visibleSheetIndex()] || {}; return meta.formats || {}; }; /** Tracked dropdown menu for ribbon buttons (borders, aligns, rotation, ⋮). */ const trackedRibbonMenus = new Map(); const openRibbonMenu = (btn, items) => { const prev = trackedRibbonMenus.get(btn); if (prev) { prev(); trackedRibbonMenus.delete(btn); return; } const menu = document.createElement("div"); menu.className = "xlsx-structure-menu xlsx-ribbon-menu"; menu.setAttribute("role", "menu"); items.forEach((entry) => { if (entry.sep) { menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" })); return; } const b = document.createElement("button"); b.type = "button"; b.className = `btn-action xlsx-structure-item${entry.danger ? " xlsx-context-danger" : ""}`; b.setAttribute("role", "menuitem"); if (entry.icon) { const ic = document.createElement("i"); ic.setAttribute("data-lucide", entry.icon); ic.className = "xlsx-menu-icon"; ic.setAttribute("aria-hidden", "true"); b.appendChild(ic); } const lab = document.createElement("span"); lab.className = "xlsx-menu-label"; lab.textContent = entry.label; b.appendChild(lab); b.addEventListener("click", () => { const d = trackedRibbonMenus.get(btn); if (d) { d(); trackedRibbonMenus.delete(btn); } entry.onPick(); }); menu.appendChild(b); }); enableArrowNav(menu, ".xlsx-structure-item"); const toolbar = btn.closest(".xlsx-toolbar"); toolbar.appendChild(menu); anchorUnder(menu, toolbar, btn); safeCreateIcons(); btn.setAttribute("aria-expanded", "true"); trackedRibbonMenus.set(btn, trackDismissable(menu, { toggle: btn, onClose: () => { trackedRibbonMenus.delete(btn); btn.setAttribute("aria-expanded", "false"); menu.remove(); }, })); }; const searchBtn = area.querySelector("#xs-search"); if (searchBtn) searchBtn.addEventListener("click", () => { const fi = area.querySelector("#xlsx-find-input"); if (!fi) return; fi.scrollIntoView({ block: "nearest" }); fi.focus(); }); const printBtn = area.querySelector("#xs-print"); if (printBtn) printBtn.addEventListener("click", () => printSheet()); // Paint format: capture the active cell's style, apply on the next click. // #184 — « Peindre le format » copies the WHOLE format of the source cell, not // a subset of it, so the target ends up identical. This includes the case the // tool used to refuse: a **plain** source onto a coloured/sized target now // RESETS the target (empty values clear the declaration) instead of doing // nothing at all. const BORDER_STYLES = new Set(["dashed", "dotted", "double", "solid"]); /** `rgb(…)` / `#rrggbb` → `#rrggbb` (null quand transparent/non-couleur). */ const cssToHex = (css) => { const s = String(css || "").trim(); if (/^#[0-9a-fA-F]{6}$/.test(s)) return `#${s.slice(1).toLowerCase()}`; const m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/.exec(s); if (!m) return null; if (m[4] !== undefined && Number(m[4]) === 0) return null; const hex = [m[1], m[2], m[3]].map((v) => Number(v).toString(16).padStart(2, "0")).join(""); return `#${hex}`; }; /** Full format of a cell, as a `cell` style op payload. */ const captureFormat = (td) => { const st = td.style; const fontSize = st.fontSize ? Number.parseFloat(st.fontSize) : NaN; const deco = st.textDecoration || ""; const borderStyle = [st.borderLeftStyle, st.borderTopStyle] .find((v) => v && v !== "none") || ""; const style = { bold: st.fontWeight === "600" || st.fontWeight === "bold" || st.fontWeight === "700", italic: st.fontStyle === "italic", underline: deco.includes("underline"), strike: deco.includes("line-through"), font_color: cssToHex(st.color) || "", fill_color: cssToHex(st.backgroundColor || st.background) || "", font_size: Number.isFinite(fontSize) ? Math.min(72, Math.max(6, Math.round(fontSize))) : 11, align: ["left", "center", "right", "justify"].includes(st.textAlign) ? st.textAlign : "left", wrap: st.whiteSpace === "normal", // Vertical : 90/180. Diagonal : `rotate(Xdeg)` CSS (horaire) relu en // angle Excel (anti-horaire, borné à ±90 comme le backend). rotation: (() => { if (st.writingMode === "vertical-rl") { return (st.transform || "").includes("-1") ? 180 : 90; } const m = /rotate\(\s*(-?\d+(?:\.\d+)?)\s*deg\s*\)/.exec(st.transform || ""); return m ? Math.max(-90, Math.min(90, Math.round(-Number(m[1])))) : 0; })(), number_format: sheetFormatsOf()[cellName(td)] || "General", }; // `valign` n'est envoyé que si la source en porte un valide (top/middle/ // bottom) : `""` faisait rejeter tout le PUT en 400 côté backend. if (["top", "middle", "bottom"].includes(st.verticalAlign)) { style.valign = st.verticalAlign; } if (borderStyle) { style.border = "all"; // openpyxl ne connaît pas `solid` (CSS) : son équivalent est `thin`. style.border_style = borderStyle === "solid" ? "thin" : BORDER_STYLES.has(borderStyle) ? borderStyle : "thin"; style.border_color = cssToHex(st.borderLeftColor) || cssToHex(st.borderColor) || "#000000"; } else { style.border = "none"; } return style; }; let paintStyle = null; let paintOnceHandler = null; const paintToggle = area.querySelector("#xs-paint"); const cancelPaintKeys = (e) => { if (e.key === "Escape" && paintStyle) stopPaint(); }; const stopPaint = () => { paintStyle = null; // #184 — cancelling (Escape, second click on the button) MUST also drop the // pending "apply on next click" listener: it used to survive and repaint a // stale style on the next grid click. if (paintOnceHandler) { area.removeEventListener("click", paintOnceHandler); paintOnceHandler = null; } document.removeEventListener("keydown", cancelPaintKeys); if (paintToggle) { paintToggle.classList.remove("active"); paintToggle.setAttribute("aria-pressed", "false"); } }; if (paintToggle) paintToggle.addEventListener("click", () => { if (paintStyle) { stopPaint(); return; } const td = activeStyleCell(); if (!td) { showToast(t("xlsx.paint_pick_source"), "info"); return; } // A source with no explicit format is legitimate: it means "reset". paintStyle = captureFormat(td); paintToggle.classList.add("active"); paintToggle.setAttribute("aria-pressed", "true"); document.addEventListener("keydown", cancelPaintKeys); showToast(t("xlsx.paint_pick_target"), "info"); paintOnceHandler = (e) => { if (e.target && e.target.closest && e.target.closest("#xs-paint")) return; // Toolbar/menu/modal/colour-panel clicks must neither paint nor disarm. if (e.target && e.target.closest && e.target.closest( ".xlsx-toolbar,.xlsx-minibar,.xlsx-structure-menu,.xlsx-context-menu," + ".xlsx-export-menu,.xlsx-colour-panel,.obsigate-modal-overlay" )) return; const captured = paintStyle; stopPaint(); if (captured) runCellStyle(captured); }; // Area bubble runs after the grid's own click handlers: the selection is // already updated when this fires. area.addEventListener("click", paintOnceHandler); }); // Zoom: pure client-side scaling, persisted per browser. const ZOOM_KEY = "obsigate_xlsx_zoom"; const applyZoom = (z) => { const host = area.querySelector(".xlsx-panels"); if (host) host.style.zoom = z === 100 ? "" : `${z}%`; const sel = area.querySelector("#xs-zoom"); if (sel) sel.value = String(z); try { localStorage.setItem(ZOOM_KEY, String(z)); } catch (_) { /* private mode */ } }; const zoomSel = area.querySelector("#xs-zoom"); if (zoomSel) { let z = 100; try { z = Number(localStorage.getItem(ZOOM_KEY)) || 100; } catch (_) { /* ignore */ } if (![50, 75, 90, 100, 125, 150, 200].includes(z)) z = 100; applyZoom(z); zoomSel.addEventListener("change", () => applyZoom(Number(zoomSel.value) || 100)); } // Quick number formats. const quickFmt = (id, fmt) => { const b = area.querySelector(id); if (b) b.addEventListener("click", () => runCellStyle({ number_format: fmt })); }; quickFmt("#xs-cur", '#,##0.00 "€"'); quickFmt("#xs-pct", "0.0%"); quickFmt("#xs-num123", "0.00"); const numSel = area.querySelector("#xs-numfmt"); if (numSel) numSel.addEventListener("change", () => { if (!numSel.value) return; runCellStyle({ number_format: numSel.value }); numSel.value = ""; }); const shiftDecimals = (delta) => { const td = activeStyleCell(); if (!td) return; let fmt = sheetFormatsOf()[cellName(td)] || "General"; if (fmt === "@") return; if (fmt === "General") { if (delta < 0) return; runCellStyle({ number_format: "0.0" }); return; } const secs = fmt.split(";"); let head = secs[0]; if (/[yYmMdDhHsS]/.test(head) && !/\.\d/.test(head)) return; // dates: leave alone const m = head.match(/\.0+/); if (m) { let zeros = m[0].slice(1); zeros = delta > 0 ? `${zeros}0` : zeros.slice(0, -1); head = zeros ? head.replace(/\.0+/, `.${zeros}`) : head.replace(/\.0+/, ""); } else if (delta > 0) { head = head.replace(/(%?"[^"]*"?%?)$/, ".0$1"); if (!head.includes(".0")) head += ".0"; } else { return; } secs[0] = head; runCellStyle({ number_format: secs.join(";") }); }; const decMinus = area.querySelector("#xs-decminus"); if (decMinus) decMinus.addEventListener("click", () => shiftDecimals(-1)); const decPlus = area.querySelector("#xs-decplus"); if (decPlus) decPlus.addEventListener("click", () => shiftDecimals(1)); // Font size + B/I/S/U toggles (state reflected by updateSheetsToolbarStates). const currentFontSize = () => { const td = activeStyleCell(); if (!td) return 11; const px = td.style.fontSize ? Number.parseFloat(td.style.fontSize) : NaN; return Number.isFinite(px) ? Math.round(px) : 11; }; const fsMinus = area.querySelector("#xs-fsminus"); if (fsMinus) fsMinus.addEventListener("click", () => { runCellStyle({ font_size: Math.max(6, currentFontSize() - 1) }); }); const fsPlus = area.querySelector("#xs-fsplus"); if (fsPlus) fsPlus.addEventListener("click", () => { runCellStyle({ font_size: Math.min(72, currentFontSize() + 1) }); }); const fsSize = area.querySelector("#xs-fssize"); if (fsSize) fsSize.addEventListener("change", () => { const size = Number(fsSize.value); if (Number.isFinite(size)) runCellStyle({ font_size: Math.min(72, Math.max(6, Math.round(size))) }); }); const styleToggle = (id, make) => { const b = area.querySelector(id); if (b) b.addEventListener("click", () => runCellStyle(make())); }; styleToggle("#xs-b", () => ({ bold: !(activeStyleCell() && ["600", "bold", "700"].includes(activeStyleCell().style.fontWeight)) })); styleToggle("#xs-i", () => ({ italic: !(activeStyleCell() && activeStyleCell().style.fontStyle === "italic") })); styleToggle("#xs-s", () => ({ strike: !hasStrike() })); styleToggle("#xs-u", () => ({ underline: !hasUnderline() })); // #183 — the Sheets-like colour panel replaces the native colour input on the // ribbon. Opening/positioning/dismissal reuse the viewer machinery // (anchorUnder + trackDismissable); picking a colour applies it and closes. let dismissColourPanel = null; const closeColourPanel = () => { if (dismissColourPanel) { dismissColourPanel(); dismissColourPanel = null; } area.querySelectorAll("[data-xlsx-colour][aria-expanded='true']") .forEach((b) => b.setAttribute("aria-expanded", "false")); }; /** Open the panel for a ribbon colour button (`data-xlsx-colour="font|fill"`). */ const openColourPanel = (btn) => { if (dismissColourPanel) { closeColourPanel(); return; } // Ribbon buttons carry `data-xlsx-colour`; the Mise en forme rows carry // `data-colour-menu`. Both open the same panel. const kind = (btn.dataset.xlsxColour || btn.dataset.colourMenu) === "fill" ? "fill" : "font"; const native = area.querySelector( kind === "fill" ? '[data-xlsx-native-for="xs-bgcolor"]' : '[data-xlsx-native-for="xs-fgcolor"]' ); const swatchId = kind === "fill" ? "xs-bgcolor" : "xs-fgcolor"; const td = activeStyleCell(); // Reflect the colour already on the cell so the check mark starts in the // right place (a cell with no explicit colour shows none selected). const current = normHex(kind === "fill" ? (td ? cssToHex(td.style.backgroundColor || td.style.background) : null) : (td ? cssToHex(td.style.color) : null)) || ""; const { el: panel, sync } = createColourPicker({ kind, value: current, custom: current || (native ? normHex(native.value) : "") || "", onPick: (colour) => { closeColourPanel(); // Mirror the colour on the button so the user sees the choice stick. const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`); if (sw) sw.style.background = colour; runCellStyle(kind === "fill" ? { fill_color: colour } : { font_color: colour }); }, onReset: () => { closeColourPanel(); const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`); if (sw) sw.style.background = ""; runCellStyle(kind === "fill" ? { fill_color: "" } : { font_color: "" }); }, onCustom: () => { // The native picker stays the only way to reach an arbitrary colour // (the panel offers 80 + 8 curated ones). if (native && typeof native.click === "function") native.click(); }, onUnavailable: (label) => showToast(t("xlsx.colour_unavailable", { feature: label }), "info"), }); panel.dataset.xlsxColourPanel = kind; const toolbar = btn.closest(".xlsx-toolbar") || area; toolbar.appendChild(panel); anchorUnder(panel, toolbar, btn); safeCreateIcons(); btn.setAttribute("aria-expanded", "true"); dismissColourPanel = trackDismissable(panel, { toggle: btn, onClose: () => { dismissColourPanel = null; panel.remove(); btn.setAttribute("aria-expanded", "false"); }, }); sync(current); }; area.querySelectorAll("[data-xlsx-colour]").forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); openColourPanel(btn); }); }); // Keep the ribbon swatch in step when the native picker (opened from the // panel's « + ») yields an arbitrary colour. area.querySelectorAll("[data-xlsx-native-for]").forEach((input) => { input.addEventListener("change", (e) => { const swatchId = input.dataset.xlsxNativeFor; const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`); if (sw) sw.style.background = e.target.value; const kind = swatchId === "xs-bgcolor" ? "fill" : "font"; runCellStyle(kind === "fill" ? { fill_color: e.target.value } : { font_color: e.target.value }); }); }); // Borders / merge / alignments / wrap / rotation. const bordersBtn = area.querySelector("#xs-borders"); if (bordersBtn) bordersBtn.addEventListener("click", () => openRibbonMenu(bordersBtn, [ { icon: "grid", label: t("xlsx.border_all"), onPick: () => runCellStyle({ border: "all" }) }, { icon: "box-select", label: t("xlsx.border_outer"), onPick: () => runCellStyle({ border: "outer" }) }, { icon: "ban", label: t("xlsx.border_none"), onPick: () => runCellStyle({ border: "none" }) }, ])); const mergeBtn = area.querySelector("#xs-merge"); if (mergeBtn) mergeBtn.addEventListener("click", () => { const b = selectionBounds(); const panel = visiblePanel(); if (!b || !selection || selection.panel !== panel || (b.r1 === b.r2 && b.c1 === b.c2)) { showToast(t("xlsx.format_merge_needs_range"), "info"); return; } const label = rangeLabel(); const merges = sheets[visibleSheetIndex()]?.merges || []; const op = merges.includes(label) ? "unmerge" : "merge"; runStyle([{ op, sheet: styleSheetName(), range: label }]); }); const alignhBtn = area.querySelector("#xs-alignh"); if (alignhBtn) alignhBtn.addEventListener("click", () => openRibbonMenu(alignhBtn, [ { icon: "align-left", label: t("xlsx.format_align_left"), onPick: () => runCellStyle({ align: "left" }) }, { icon: "align-center", label: t("xlsx.format_align_center"), onPick: () => runCellStyle({ align: "center" }) }, { icon: "align-right", label: t("xlsx.format_align_right"), onPick: () => runCellStyle({ align: "right" }) }, { icon: "align-justify", label: t("xlsx.format_align_justify"), onPick: () => runCellStyle({ align: "justify" }) }, ])); const alignvBtn = area.querySelector("#xs-alignv"); if (alignvBtn) alignvBtn.addEventListener("click", () => openRibbonMenu(alignvBtn, [ { icon: "align-start-vertical", label: t("xlsx.valign_top"), onPick: () => runCellStyle({ valign: "top" }) }, { icon: "align-center-vertical", label: t("xlsx.valign_middle"), onPick: () => runCellStyle({ valign: "middle" }) }, { icon: "align-end-vertical", label: t("xlsx.valign_bottom"), onPick: () => runCellStyle({ valign: "bottom" }) }, ])); const wrapBtn = area.querySelector("#xs-wrap"); if (wrapBtn) wrapBtn.addEventListener("click", () => { const td = activeStyleCell(); const on = !(td && td.style.whiteSpace === "normal"); runCellStyle({ wrap: on }); }); const rotBtn = area.querySelector("#xs-rot"); if (rotBtn) rotBtn.addEventListener("click", () => openRibbonMenu(rotBtn, [ { icon: "minus", label: t("xlsx.rotation_0"), onPick: () => runCellStyle({ rotation: 0 }) }, { icon: "trending-up", label: t("xlsx.rotation_45"), onPick: () => runCellStyle({ rotation: 45 }) }, { icon: "trending-down", label: t("xlsx.rotation_315"), onPick: () => runCellStyle({ rotation: -45 }) }, { icon: "arrow-up", label: t("xlsx.rotation_90"), onPick: () => runCellStyle({ rotation: 90 }) }, ])); // Shared flows: insert link, edit comment, insert SUM. const listSeparator = () => (formulaLocale() === "fr" ? ";" : ","); const insertLinkFlow = async () => { const panel = visiblePanel(); const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null; if (!td) return; const url = await showPrompt({ title: t("xlsx.link_add"), message: t("xlsx.link_url"), value: "https://" }); if (!url) return; if (!/^https?:\/\//i.test(url.trim())) { showToast(t("xlsx.link_invalid"), "error"); return; } const label = await showPrompt({ title: t("xlsx.link_add"), message: t("xlsx.link_label"), value: url.trim() }); if (label === null) return; const clean = (s) => String(s).replace(/"/g, '""'); const sep = listSeparator(); const formula = label ? `=LIEN("${clean(url.trim())}"${sep}"${clean(label)}")` : `=LIEN("${clean(url.trim())}")`; setCellValue(td, formula); refreshPanelFormulas(panel); }; const editCommentFlow = async () => { const panel = visiblePanel(); const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null; if (!td) return; const sheet = styleSheetName(); const ref = cellName(td); const meta = sheets[visibleSheetIndex()] || {}; const existing = (meta.comments || {})[ref] || ""; const text = await showPrompt({ title: t("xlsx.comment_add"), message: t("xlsx.comment_prompt"), value: existing }); if (text === null) return; if (!text.trim()) runStyle([{ op: "comment_clear", sheet, ref }]); else runStyle([{ op: "comment_set", sheet, ref, text: text.trim() }]); }; const insertSumFlow = () => { const panel = visiblePanel(); const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null; if (!td) return; const here = parseRef(cellName(td)); if (!here) return; const sep = listSeparator(); const numAt = (r, c) => { const cell = findTd(panel, r, c); if (!cell) return null; const v = Number.parseFloat(String(cellTextOf(cell)).replace(",", ".")); return Number.isFinite(v) ? v : null; }; // Prefer the numbers stacked directly above the active cell… let top = null; for (let r = here.row - 1; r >= Math.max(1, here.row - 100); r--) { if (numAt(r, here.col) === null) break; top = r; } const fn = formulaLocale() === "fr" ? "SOMME" : "SUM"; let formula = null; if (top !== null) { formula = `=${fn}(${columnName(here.col)}${top}:${columnName(here.col)}${here.row - 1})`; } else { const b = selectionBounds(); if (b && selection && selection.panel === panel && (b.r2 > b.r1 || b.c2 > b.c1)) { formula = `=${fn}(${columnName(b.c1)}${b.r1}:${columnName(b.c2)}${b.r2})`; } } if (!formula) { showToast(t("xlsx.sum_empty"), "info"); return; } setCellValue(td, formula); refreshPanelFormulas(panel); }; // Overflow menu: singletons that deserve no permanent button. const moreBtn = area.querySelector("#xs-more"); if (moreBtn) moreBtn.addEventListener("click", () => openRibbonMenu(moreBtn, [ { icon: "snowflake", label: t("xlsx.format_freeze"), onPick: () => freezeAtSelection() }, { icon: "sun", label: t("xlsx.format_unfreeze"), onPick: () => runStyle([{ op: "freeze", sheet: styleSheetName(), cell: "" }]) }, { sep: true }, { icon: "arrow-up", label: t("xlsx.sort_asc"), onPick: () => sortFromToolbar("asc") }, { icon: "arrow-down", label: t("xlsx.sort_desc"), onPick: () => sortFromToolbar("desc") }, { icon: "filter", label: t("xlsx.find_placeholder"), onPick: () => area.querySelector("#xlsx-find-input")?.focus() }, { sep: true }, { icon: "move-horizontal", label: t("xlsx.format_col_width"), onPick: () => promptColumnWidth() }, { icon: "maximize-2", label: t("xlsx.format_col_fit"), onPick: () => autoFitSelection("col") }, { icon: "move-vertical", label: t("xlsx.format_row_height"), onPick: () => promptRowHeight() }, { icon: "maximize-2", label: t("xlsx.format_row_fit"), onPick: () => autoFitSelection("row") }, { sep: true }, { icon: "link", label: t("xlsx.link_add"), onPick: () => insertLinkFlow() }, { icon: "message-square", label: t("xlsx.comment_add"), onPick: () => editCommentFlow() }, ])); const freezeAtSelection = () => { const ref = selectionRange().split(":")[0]; runStyle([{ op: "freeze", sheet: styleSheetName(), cell: ref }]); }; const sortFromToolbar = (dir) => { const panel = visiblePanel(); const b = selection && selection.panel === panel ? selectionBounds() : null; const ref = activeTd && activeTd.closest(".xlsx-panel") === panel ? parseRef(cellName(activeTd)) : null; const col = b ? b.c1 : ref ? ref.col : 1; applySort(panel, col, dir); }; // ══ Tailles de colonnes / de lignes — manuel et automatique ═══════════ // Standards visés : Excel et Google Sheets. // · unités : largeur en CARACTÈRES (`Column Width`, openpyxl // `column_dimensions.width`), hauteur en POINTS (`Row Height`, // openpyxl `row_dimensions.height`) ; // · `0` = taille par défaut (le backend efface la mesure explicite — // l'équivalent de « Default Width » / du bouton Reset de Sheets) ; // · double-clic sur un bord d'en-tête = ajustement automatique au contenu // (Excel AutoFit / Sheets « Fit to data »). // Les facteurs de conversion sont exactement ceux d'applySheetMeta() // (×7 px par caractère, ×96/72 px par point) : mesure → op → rendu ne // perd jamais de pixels. const metaOf = (panel) => sheets[Number(panel.dataset.sheet)] || {}; const pxToChars = (px) => Math.round((px / 7) * 10) / 10; const pxToPts = (px) => Math.round((px * 72 / 96) * 10) / 10; /** Lignes du panneau indexées par numéro (le DOM peut sauter des rangs). */ const rowTrIndex = (panel) => { const map = new Map(); if (!panel) return map; panel.querySelectorAll("tbody tr").forEach((tr) => { const n = Number(tr.querySelector("th.xlsx-rownum")?.textContent); if (n) map.set(n, tr); }); return map; }; /** Largeur courante d'une colonne, en caractères (métadonnée ou rendu). */ const currentColWidthChars = (panel, colIdx) => { const stored = Number(metaOf(panel).colwidths?.[columnName(colIdx)]); if (Number.isFinite(stored) && stored > 0) return stored; const th = panel ? panel.querySelectorAll("thead th")[colIdx] : null; const px = th ? th.getBoundingClientRect().width : 0; return px ? pxToChars(px) : 8.43; // largeur par défaut d'Excel }; /** Hauteur courante d'une ligne, en points (métadonnée ou rendu). */ const currentRowHeightPts = (panel, rowNum) => { const stored = Number(metaOf(panel).rowheights?.[String(rowNum)]); if (Number.isFinite(stored) && stored > 0) return stored; const tr = rowTrIndex(panel).get(rowNum); const px = tr ? tr.getBoundingClientRect().height : 0; return px ? pxToPts(px) : 15; // hauteur par défaut d'Excel (15 pt) }; let _fitCtx = null; const textWidthPx = (text, cs) => { if (!_fitCtx) _fitCtx = document.createElement("canvas").getContext("2d"); _fitCtx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; let w = 0; for (const line of String(text).split("\n")) w = Math.max(w, _fitCtx.measureText(line).width); return w; }; /** Excel AutoFit / Sheets « Fit to data » : la colonne prend la largeur de * sa ligne de contenu la plus large (padding et bordure compris). */ const autoFitColumns = (panel, cols) => { if (!panel || !cols.length) return; const wanted = new Set(cols); const fits = new Map(); panel.querySelectorAll("tbody tr").forEach((tr) => { if (tr.style.display === "none") return; // ligne masquée par un filtre tr.querySelectorAll("td[data-cell]").forEach((td) => { if (td.style.display === "none") return; // cellule couverte par une fusion const ref = parseRef(td.dataset.cell); if (!ref || !wanted.has(ref.col)) return; const text = td.querySelector(".xlsx-cached") ? cellTextOf(td) : td.textContent; if (!text) return; const cs = getComputedStyle(td); const px = Math.ceil(textWidthPx(text, cs)) + (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0) + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0); if (px > (fits.get(ref.col) || 0)) fits.set(ref.col, px); }); }); const ops = []; cols.forEach((c) => { const px = fits.get(c); if (!px) return; // colonne vide : on n'impose rien const width = Math.min(255, Math.max(3, pxToChars(px))); const stored = Number(metaOf(panel).colwidths?.[columnName(c)]); if (Number.isFinite(stored) && Math.abs(stored - width) < 0.1) return; ops.push({ op: "col_width", sheet: styleSheetName(), col: columnName(c), width }); }); if (!ops.length) { showToast(t("xlsx.fit_nothing"), "info"); return; } runStyle(ops); }; /** Excel AutoFit Row Height / Sheets « Fit to data » : la ligne prend la * hauteur naturelle de son contenu (retours à la ligne et renvoi compris). */ const autoFitRows = (panel, rows) => { if (!panel || !rows.length) return; const index = rowTrIndex(panel); const ops = []; rows.forEach((rowNum) => { const tr = index.get(rowNum); if (!tr || tr.style.display === "none") return; const prev = tr.style.height; tr.style.height = "auto"; let need = 0; tr.querySelectorAll("td[data-cell]").forEach((td) => { if (td.style.display === "none") return; const cs = getComputedStyle(td); need = Math.max(need, (td.scrollHeight || 0) + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0)); }); tr.style.height = prev; if (!need) return; const height = Math.min(409, Math.max(9, pxToPts(need))); const stored = Number(metaOf(panel).rowheights?.[String(rowNum)]); if (Number.isFinite(stored) && Math.abs(stored - height) < 0.1) return; ops.push({ op: "row_height", sheet: styleSheetName(), row: rowNum, height }); }); if (!ops.length) { showToast(t("xlsx.fit_nothing"), "info"); return; } runStyle(ops); }; /** Ajustement automatique sur la sélection courante (ou la cellule active) : * c'est l'entrée de menu, le geste et le clic droit d'en-tête qui l'appellent. */ const autoFitSelection = (axis) => { const panel = visiblePanel(); if (!panel) return; const b = selection && selection.panel === panel ? selectionBounds() : null; const ref = parseRef(selectionRange().split(":")[0]) || {}; if (axis === "col") { const cols = []; if (b) { for (let c = b.c1; c <= b.c2; c++) cols.push(c); } else cols.push(ref.col || 1); autoFitColumns(panel, cols); } else { const rows = []; if (b) { for (let r = b.r1; r <= b.r2; r++) rows.push(r); } else rows.push(ref.row || 1); autoFitRows(panel, rows); } }; const promptColumnWidth = async () => { const panel = visiblePanel(); const b = selection && selection.panel === panel ? selectionBounds() : null; const cols = []; if (b) { for (let c = b.c1; c <= b.c2; c++) cols.push(c); } else cols.push((parseRef(selectionRange().split(":")[0]) || { col: 1 }).col); const letters = cols.map(columnName); const answer = await showPrompt({ title: t("xlsx.format_col_width"), message: t("xlsx.format_col_prompt", { col: letters.join(", ") }), // Comme Excel / Sheets : la valeur courante est pré-remplie. value: String(currentColWidthChars(panel, cols[0])), }); if (answer === null || answer === undefined || String(answer).trim() === "") return; const width = Number(String(answer).replace(",", ".")); if (!Number.isFinite(width) || width < 0 || width > 255) return; runStyle(letters.map((col) => ({ op: "col_width", sheet: styleSheetName(), col, width }))); }; const promptRowHeight = async () => { const panel = visiblePanel(); const b = selection && selection.panel === panel ? selectionBounds() : null; const rows = []; if (b) { for (let r = b.r1; r <= b.r2; r++) rows.push(r); } else rows.push((parseRef(selectionRange().split(":")[0]) || { row: 1 }).row); const answer = await showPrompt({ title: t("xlsx.format_row_height"), message: t("xlsx.format_row_prompt", { row: rows.join(", ") }), // Comme Excel / Sheets : la valeur courante est pré-remplie. value: String(currentRowHeightPts(panel, rows[0])), }); if (answer === null || answer === undefined || String(answer).trim() === "") return; const height = Number(String(answer).replace(",", ".")); if (!Number.isFinite(height) || height < 0 || height > 409) return; runStyle(rows.map((row) => ({ op: "row_height", sheet: styleSheetName(), row, height }))); }; // Floating mini-bar on multi-cell selections: link, comment, clear filter, SUM. const minibar = document.createElement("div"); minibar.className = "xlsx-minibar"; minibar.id = "xlsx-minibar"; minibar.hidden = true; const miniIcon = (name) => ``; minibar.innerHTML = `` + `` + `` + ``; area.appendChild(minibar); safeCreateIcons(); minibar.addEventListener("click", (e) => { const btn = e.target && e.target.closest ? e.target.closest("[data-mini]") : null; if (!btn) return; const kind = btn.dataset.mini; if (kind === "link") insertLinkFlow(); else if (kind === "comment") editCommentFlow(); else if (kind === "clear") sortResetBtn.click(); else if (kind === "sum") insertSumFlow(); }); const updateMinibar = () => { const b = selection && selection.panel === visiblePanel() ? selectionBounds() : null; if (!editable || !b || (b.r1 === b.r2 && b.c1 === b.c2)) { minibar.hidden = true; return; } const panel = visiblePanel(); const anchor = findTd(panel, b.r2, b.c2) || findTd(panel, b.r1, b.c1); if (!anchor) { minibar.hidden = true; return; } const r = anchor.getBoundingClientRect(); const vw = window.innerWidth || 1024; const vh = window.innerHeight || 768; minibar.hidden = false; const mw = minibar.offsetWidth || 160; const mh = minibar.offsetHeight || 36; minibar.style.left = `${Math.max(4, Math.min(r.right - mw, vw - mw - 4))}px`; minibar.style.top = `${Math.max(4, Math.min(r.bottom + 6, vh - mh - 4))}px`; }; const hideMinibar = () => { minibar.hidden = true; }; panelEls.forEach((panel) => { const wrap = panel.querySelector(".csv-table-wrapper"); if (wrap) wrap.addEventListener("scroll", hideMinibar, { passive: true }); }); // ══ #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; }; /** The selected range as a matrix of RAW values (formulas travel, Excel-like). */ const selectionRawMatrix = () => { 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++) { const td = findTd(selection.panel, r, c); line.push(td ? cellRaw(td) : ""); } rows.push(line); } return rows; }; /** Copy the selected range. Returns false when there is nothing to copy. */ const copySelection = () => { const b = selectionBounds(); // Raw content (not display values): a copied formula is pasted as a // formula and recomputed at its target, like a spreadsheet. const rows = selectionRawMatrix(); 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); }; // #181-A1 — the two cell states. A SELECTED cell shows the outline and no // caret; an EDITING cell shows the caret and accepts typing. `enterEditing` // optionally empties the cell first (first keystroke on a selected cell, like // Sheets/Excel); `exitEditing` is idempotent and safe on the blur path. const enterEditing = (td, { replace = false } = {}) => { if (!td) return; if (replace) { // Drop the value and the helper spans (cached shadow, comment dot, // formula link) so the keystroke starts from an empty cell. td.querySelectorAll(".xlsx-cached,.xlsx-comment-dot").forEach((el) => el.remove()); td.textContent = ""; delete td.dataset.formula; delete td.dataset.display; td.classList.add("xlsx-dirty"); } td.dataset.editing = "1"; td.classList.add("xlsx-editing"); // Focus first, then let the caller place the caret. `editingRequested` // survives the focus event (the cell handler would clear the state). editingRequested = true; if (document.activeElement !== td) td.focus(); editingRequested = false; syncFormulaBar(td); refreshSaveState(); }; const exitEditing = (td) => { if (!td) return; td.dataset.editing = ""; td.classList.remove("xlsx-editing"); }; const startEditing = (td) => { enterEditing(td); placeCaretAtEnd(td); }; /** * Put the caret where the user double-clicked, instead of at the end: a * Range built from the click offset selects the word under the pointer, which * is the Sheets/Excel behaviour (double-click edits in place). */ const placeCaretAtPoint = (td, clientX, clientY) => { const sel = window.getSelection ? window.getSelection() : null; if (!sel || typeof document.caretRangeFromPoint !== "function") { placeCaretAtEnd(td); return; } let range = null; try { range = document.caretRangeFromPoint(clientX, clientY); } catch (_) { range = null; } if (!range || !td.contains(range.startContainer)) { placeCaretAtEnd(td); return; } sel.removeAllRanges(); sel.addRange(range); }; /** 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)); } enterEditing(td); 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; const beforeFormula = td.dataset.formula ?? null; td.textContent = ""; delete td.dataset.formula; delete td.dataset.display; td.querySelectorAll(".xlsx-comment-dot").forEach((el) => el.remove()); td.classList.toggle("xlsx-dirty", (td.dataset.orig ?? "") !== ""); recordEdit(td, before, "", beforeFormula, null); 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; const beforeFormula = td.dataset.formula ?? null; td.textContent = value; delete td.dataset.formula; delete td.dataset.display; td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? "")); recordEdit(td, before, value, beforeFormula, isFormulaText(value) ? value.trim() : null); 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 = barTextOf(startTd); refreshSaveState(); refreshPanelFormulas(panel); 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 = barTextOf(target); 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 = barTextOf(anchorTd); // 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); // ══ #185 — barre de menus façon Google Sheets ═══════════════════════════ // The ten menus are BUILT by frontend/js/xlsx/menus.js (pure data); this block // only wires them: open/close with the #179 dismissal contract, accordion // sections (Nombre, Thème, Zoom, …) and one registry mapping each entry's // symbolic action to an existing flow. Entries ObsiGate does not implement // say so out loud instead of doing nothing. const menuBar = area.querySelector(".xlsx-menubar"); if (menuBar) { const activeRefOf = () => parseRef(selectionRange().split(":")[0]) || { row: 1, col: 1 }; const sheetNameOf = () => sheets[visibleSheetIndex()]?.name || ""; const fileNameOf = () => String(data.path || "").split("/").pop(); /** A theme token as `#rrggbb` for a style payload (never hardcoded). */ const themeColour = (token, fallback) => { let raw = ""; try { raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim(); } catch (_) { /* no layout engine */ } return normHex(raw) || fallback; }; // ── presentation toggles (Affichage ▸ Afficher, Outils ▸ Accessibilité) ── const gridBtn = area.querySelector("#xs-grid"); const toggleGrid = () => { const hidden = viewerEl.classList.toggle("xlsx-no-grid"); const on = !hidden; if (gridBtn) { gridBtn.setAttribute("aria-pressed", String(on)); gridBtn.classList.toggle("active", on); } return on; }; if (gridBtn) gridBtn.addEventListener("click", toggleGrid); const toggleFullscreen = () => { const root = document.documentElement; if (document.fullscreenElement) { if (document.exitFullscreen) document.exitFullscreen(); return; } const ask = root.requestFullscreen || root.webkitRequestFullscreen; if (!ask) { showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info"); return; } try { const p = ask.call(root); if (p && typeof p.catch === "function") { p.catch(() => showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info")); } } catch (_) { showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info"); } }; // ── Modifier ▸ Collage spécial ▸ valeurs uniquement ───────────────────── const valuesOnlyMatrix = (rows, sheetName) => { const locale = formulaLocale(); const scope = createScope({ readRaw: readRawCell, sheet: sheetName, locale }); return rows.map((line) => line.map((raw) => { const text = String(raw === null || raw === undefined ? "" : raw); if (!isFormulaText(text)) return text; try { return String(displayValue(evaluate(parseFormula(text), scope), locale)); } catch (_) { return text; } })); }; const pasteValuesOnly = async () => { const td = activeTd && area.contains(activeTd) ? activeTd : null; if (!td) return; const name = sheetNameOf(); if (rangeClipboard && rangeClipboard.rows && rangeClipboard.rows.length) { const tsv = toTsv(valuesOnlyMatrix(rangeClipboard.rows, name)); if (applyPaste(tsv, td)) return; } let text = ""; try { if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.readText) { text = await navigator.clipboard.readText(); } } catch (_) { /* permission denied */ } const matrix = text ? parseMatrix(text) : []; if (!matrix.length || !applyPaste(toTsv(valuesOnlyMatrix(matrix, name)), td)) { showToast(t("xlsx.paste_empty"), "info"); } }; // ── Insertion ▸ rows / columns / cells ───────────────────────────────── const insertRows = (where) => { const panel = visiblePanel(); const name = sheetNameOf(); const b = selectionBounds(); if (b && selection && selection.panel === panel) { structureAction({ op: "row_insert", sheet: name, count: b.r2 - b.r1 + 1, at: where < 0 ? b.r1 : b.r2 + 1, }); return; } const ref = activeRefOf(); structureAction({ op: "row_insert", sheet: name, at: where < 0 ? ref.row : ref.row + 1 }); }; const insertCols = (where) => { const panel = visiblePanel(); const name = sheetNameOf(); const b = selectionBounds(); if (b && selection && selection.panel === panel) { structureAction({ op: "col_insert", sheet: name, count: b.c2 - b.c1 + 1, at: where < 0 ? b.c1 : b.c2 + 1, }); return; } const ref = activeRefOf(); structureAction({ op: "col_insert", sheet: name, at: where < 0 ? ref.col : ref.col + 1 }); }; // ── Insertion ▸ Fonction : insert the call, caret inside the parens ───── const insertFunctionFlow = async () => { const panel = visiblePanel(); const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null; if (!td) return; const answer = await showPrompt({ title: t("xlsx.mi_function"), message: t("xlsx.mi_function_hint"), value: formulaLocale() === "fr" ? "SOMME" : "SUM", }); if (answer === null) return; const name = String(answer).trim().toUpperCase(); if (!name) return; if (!/^[A-Z][A-Z0-9._]*$/.test(name)) { showToast(t("xlsx.name_box_invalid", { ref: name }), "info"); return; } const text = `=${name}()`; setCellValue(td, text); enterEditing(td); syncFormulaBar(td); const sel = window.getSelection ? window.getSelection() : null; if (sel && document.createRange && td.firstChild) { const range = document.createRange(); range.setStart(td.firstChild, Math.max(0, text.length - 1)); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } }; // ── Format ▸ Couleurs alternées (batchées : 50 ops par requête) ──────── const applyBandedRows = async () => { const panel = visiblePanel(); const b = selectionBounds(); if (!b || !selection || selection.panel !== panel || b.r2 === b.r1) { showToast(t("xlsx.format_merge_needs_range"), "info"); return; } const name = sheetNameOf(); const band = themeColour("--accent-bg", t("xlsx.mi_banded_default")); const ops = []; for (let r = b.r1; r <= b.r2; r++) { const shaded = (r - b.r1) % 2 === 0; ops.push({ op: "cell", sheet: name, range: `${columnName(b.c1)}${r}:${columnName(b.c2)}${r}`, style: { fill_color: shaded ? band : "" }, }); } for (let i = 0; i < ops.length; i += 50) { const chunk = ops.slice(i, i + 50); try { await runStyle(chunk); } catch (_) { showToast(t("xlsx.mi_banded_done", { n: Math.min(50, ops.length - i) }), "info"); return; } } showToast(t("xlsx.mi_banded_done", { n: b.r2 - b.r1 + 1 }), "success"); }; // ── Format ▸ Thème : the theme list is built on demand ───────────────── const fillThemeSection = async (row) => { const sub = row.nextElementSibling; if (!sub) return; if (sub.dataset.filled !== "1") { let entries = []; try { const mod = await import("./themes.js"); entries = typeof mod.listThemes === "function" ? mod.listThemes() : []; } catch (_) { entries = []; } sub.innerHTML = entries.length ? entries.map((theme) => ( `` )).join("") : `
${escapeHtml(t("xlsx.menu_unavailable_hint"))}
`; sub.dataset.filled = "1"; } row.setAttribute("aria-expanded", "true"); sub.hidden = false; }; // ── Données ▸ nettoyage / découpe ────────────────────────────────────── const requireSelection = () => { const panel = visiblePanel(); const b = selectionBounds(); if (!b || !selection || selection.panel !== panel) { showToast(t("xlsx.format_merge_needs_range"), "info"); return null; } return { panel, b }; }; const removeDuplicateRows = async () => { const sel = requireSelection(); if (!sel) return; const { panel, b } = sel; const seen = new Set(); const duplicates = []; for (let r = b.r1; r <= b.r2; r++) { const key = []; let filled = false; for (let c = b.c1; c <= b.c2; c++) { const td = findTd(panel, r, c); const text = td ? cellTextOf(td).trim() : ""; if (text) filled = true; key.push(text); } // Blank rows are not duplicates of each other (Sheets ignores them too). if (!filled) continue; const signature = key.join("\u0000"); if (seen.has(signature)) duplicates.push(r); else seen.add(signature); } if (!duplicates.length) { showToast(t("xlsx.mi_cleanup_dupes_none"), "info"); return; } const ok = await showConfirm({ title: t("xlsx.mi_cleanup_dupes"), message: t("xlsx.mi_cleanup_dupes_done", { n: duplicates.length }), confirmLabel: t("common.confirm"), danger: true, }); if (!ok) return; // Bottom-up: deleting the last duplicate first keeps the earlier indexes valid. const actions = duplicates.slice().sort((x, y) => y - x) .map((row) => ({ op: "row_delete", sheet: sheetNameOf(), at: row, count: 1 })); try { await putStructure(actions); } catch (err) { structureError(err, actions); } }; const trimSelection = () => { const sel = requireSelection(); if (!sel) return; const { panel, b } = sel; let cleaned = 0; 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.dataset.editing === "1") continue; const raw = cellRaw(td); if (!raw) continue; const next = isFormulaText(raw) ? raw : raw.trim().replace(/[ \t]+/g, " "); if (next === raw) continue; setCellValue(td, next); cleaned++; } } refreshPanelFormulas(panel); refreshSaveState(); showToast(t("xlsx.mi_cleanup_trim_done", { n: cleaned }), "success"); }; const splitTextToColumns = async () => { const panel = visiblePanel(); const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null; if (!td) return; const source = cellTextOf(td).trim(); if (!source) return; const separator = await showPrompt({ title: t("xlsx.mi_split"), message: t("xlsx.mi_split_sep"), value: ",", }); if (separator === null || separator === "") return; const start = parseRef(cellName(td)); if (!start) return; const parts = source.split(separator).map((part) => part.trim()); let filled = 0; parts.forEach((value, i) => { const target = findTd(panel, start.row, start.col + i); if (!target) return; if (setCellValue(target, value)) filled++; }); refreshPanelFormulas(panel); refreshSaveState(); showToast(t("xlsx.mi_split_done", { n: filled }), "success"); }; // ── File actions that need their own endpoint ────────────────────────── const fileRename = async () => { const current = fileNameOf(); const next = await showPrompt({ title: t("xlsx.mi_rename"), message: t("xlsx.rename_prompt"), value: current, }); if (!next || next === current) return; try { await api(`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, { method: "PATCH", body: JSON.stringify({ path: data.path, new_name: next }), }); showToast(t("xlsx.rename_done", { name: next }), "success"); } catch (err) { showToast(`${t("editor.save_error")}: ${err.message || err}`, "error"); } }; const fileTrash = async () => { const name = fileNameOf(); const ok = await showConfirm({ title: t("xlsx.mi_trash"), message: t("xlsx.trash_confirm", { name }), confirmLabel: t("xlsx.mi_trash"), danger: true, }); if (!ok) return; try { await api(`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, { method: "DELETE", }); showToast(t("xlsx.trash_done"), "success"); } catch (err) { showToast(`${t("editor.save_error")}: ${err.message || err}`, "error"); } }; const fileHistory = async () => { try { const mod = await import("./backups.js"); await mod.openBackupViewer(data.vault, data.path); } catch (_) { showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_history") }), "info"); } }; const fileDetails = () => { const dirty = dirtyCount(); showConfirm({ title: t("xlsx.details_title"), message: t("xlsx.details_body", { vault: data.vault || "—", path: data.path || "—", sheets: sheets.length, cells: area.querySelectorAll(".xlsx-table td[data-cell]").length, revision: revision || "—", dirty: String(dirty), }), confirmLabel: t("common.close"), }); }; const openSettings = () => { const btn = document.getElementById("config-open-btn"); if (btn) btn.click(); else showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_settings") }), "info"); }; const showWhatsNew = async () => { try { const health = await api("/api/health"); showConfirm({ title: t("xlsx.version_title"), message: t("xlsx.version_body", { version: health.version || "—", date: health.git_commit ? String(health.git_commit).slice(0, 8) : "", status: health.status || "ok", }), confirmLabel: t("common.close"), }); } catch (_) { showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_whatsnew") }), "info"); } }; const reportProblem = async () => { const report = [ `ObsiGate ${window.__OBSIGATE_VERSION || "?"}`, navigator.userAgent, `vault=${data.vault || ""} path=${data.path || ""}`, `sheets=${sheets.length} revision=${revision || "-"} dirty=${dirtyCount()}`, ].join("\n"); try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(report); showToast(t("xlsx.report_copied"), "success"); return; } } catch (_) { /* clipboard denied — fall through */ } showConfirm({ title: t("xlsx.mi_report"), message: report, confirmLabel: t("common.close"), }); }; /** action name -> implementation. Mirrors SHEETS_MENUS in menus.js. */ const menuActions = { // Fichier share: () => openShareDialog(data.vault, data.path), download: downloadOriginalFile, "export-csv": exportSheetCsv, "export-md": () => exportDocument("md"), "export-html": () => exportDocument("html"), rename: fileRename, trash: fileTrash, history: fileHistory, details: fileDetails, settings: openSettings, print: () => printSheet(), // Modifier undo: () => { undo(); }, redo: () => { redo(); }, cut: () => { if (editable) cutSelection(); }, copy: () => { copySelection(); }, paste: () => pasteFromClipboard(), "paste-values": pasteValuesOnly, "delete-rows": () => { const b = selectionBounds(); if (!b) return; structureAction( { op: "row_delete", sheet: sheetNameOf(), at: b.r1, count: b.r2 - b.r1 + 1 }, t("xlsx.confirm_delete_rows", { n: b.r2 - b.r1 + 1 }), ); }, "delete-cols": () => { const b = selectionBounds(); if (!b) return; structureAction( { op: "col_delete", sheet: sheetNameOf(), at: b.c1, count: b.c2 - b.c1 + 1 }, t("xlsx.confirm_delete_cols", { n: b.c2 - b.c1 + 1 }), ); }, "clear-contents": () => { const b = selectionBounds(); if (b) clearContents(visiblePanel(), b); }, "select-all": () => selectAllCells(visiblePanel()), "find-replace": () => replaceFlow(), // Affichage freeze: () => freezeAtSelection(), "freeze-row": () => { const ref = activeRefOf(); runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: `A${ref.row}` }]); }, "freeze-col": () => { const ref = activeRefOf(); runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: `${columnName(ref.col)}1` }]); }, unfreeze: () => runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: "" }]), "toggle-formula-bar": () => { const bar = area.querySelector(".xlsx-formula-bar"); if (bar) bar.hidden = !bar.hidden; }, "toggle-grid": toggleGrid, "toggle-headers": () => viewerEl.classList.toggle("xlsx-no-headers"), dashboard: () => { if (dashBtn) dashBtn.click(); }, zoom: (arg) => { const z = Number(arg); if (Number.isFinite(z)) applyZoom(z); }, fullscreen: toggleFullscreen, // Insertion "insert-rows-above": () => insertRows(-1), "insert-rows-below": () => insertRows(1), "insert-cols-left": () => insertCols(-1), "insert-cols-right": () => insertCols(1), "insert-cells": () => { const ref = activeRefOf(); const name = sheetNameOf(); structureAction({ op: "row_insert", sheet: name, at: ref.row }); structureAction({ op: "col_insert", sheet: name, at: ref.col }); }, link: () => insertLinkFlow(), comment: () => editCommentFlow(), function: insertFunctionFlow, // Format numfmt: (arg) => runCellStyle({ number_format: arg }), "theme-menu": (arg, item) => fillThemeSection(item), "apply-theme": (arg) => { import("./themes.js") .then((mod) => mod.setTheme(arg)) .catch(() => showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_theme") }), "info")); }, bold: () => runCellStyle({ bold: !hasBold() }), italic: () => runCellStyle({ italic: !hasItalic() }), underline: () => runCellStyle({ underline: !hasUnderline() }), strike: () => runCellStyle({ strike: !hasStrike() }), fontsize: (arg) => runCellStyle({ font_size: Number(arg) }), align: (arg) => runCellStyle({ align: arg }), valign: (arg) => runCellStyle({ valign: arg }), "wrap-on": () => runCellStyle({ wrap: true }), "wrap-off": () => runCellStyle({ wrap: false }), rotate: (arg) => runCellStyle({ rotation: Number(arg) }), "clear-format": () => runCellStyle({ bold: false, italic: false, underline: false, strike: false, // Pas de `valign` : le backend ne connaît que top/middle/bottom et // rejette `""` en 400 (même cause que le bug « Paint Format »). font_color: "", fill_color: "", align: "left", wrap: false, rotation: 0, number_format: "General", }), banded: applyBandedRows, // Tailles (Format ▸ Redimensionner) "col-width": () => promptColumnWidth(), "row-height": () => promptRowHeight(), "col-fit": () => autoFitSelection("col"), "row-fit": () => autoFitSelection("row"), // Données "sort-asc": () => sortFromToolbar("asc"), "sort-desc": () => sortFromToolbar("desc"), "sort-reset": () => sortResetBtn.click(), "filter-column": () => { const panel = visiblePanel(); const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null; const value = td ? cellTextOf(td).trim() : ""; if (!value) { showToast(t("xlsx.find_no_match"), "info"); return; } panelEls.forEach((p) => applyFilter(p, p === panel ? value : "")); findInput.value = value; runFind(); }, "named-ranges": () => { if (dashBtn) dashBtn.click(); }, "cleanup-dupes": removeDuplicateRows, "cleanup-trim": trimSelection, "split-text": splitTextToColumns, // Outils autocomplete: (arg) => { const on = arg !== "false"; formulaInput.setAttribute("list", on ? "xlsx-function-list" : ""); showToast(on ? t("xlsx.mi_autocomplete_on") : t("xlsx.mi_autocomplete_off"), "info"); }, a11y: () => { const on = viewerEl.classList.toggle("xlsx-a11y"); showToast(on ? t("xlsx.mi_a11y_on") : t("xlsx.mi_a11y_off"), "info"); }, // Gemini gemini: () => { openAssistant(); }, // Aide help: () => { const btn = document.getElementById("help-open-btn"); if (btn) btn.click(); else showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_help_sheets") }), "info"); }, whatsnew: showWhatsNew, shortcuts: () => showConfirm({ title: t("xlsx.shortcuts_title"), message: t("xlsx.shortcuts_body"), confirmLabel: t("common.close"), }), report: reportProblem, }; /** Modifier ▸ Rechercher et remplacer. */ const replaceFlow = async () => { const panel = visiblePanel(); if (!panel) return; const needle = await showPrompt({ title: t("xlsx.mi_replace"), message: t("xlsx.mi_replace_find"), value: findInput.value, }); if (needle === null || !String(needle).length) return; const replacement = await showPrompt({ title: t("xlsx.mi_replace"), message: t("xlsx.mi_replace_with"), }); if (replacement === null) return; const from = String(needle); const to = String(replacement); const scope = createScope({ readRaw: readRawCell, sheet: panelSheetName(panel), locale: formulaLocale() }); const matches = panelEls.reduce((acc, p) => acc.concat( [...p.querySelectorAll(".xlsx-table td[data-cell]")].filter( (td) => td.textContent.toLowerCase().includes(from.toLowerCase()), ), ), []); if (!matches.length) { showToast(t("xlsx.mi_replace_none"), "info"); return; } let replaced = 0; matches.forEach((td) => { const raw = cellRaw(td); const next = isFormulaText(raw) ? raw.replace(new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), to) : cellTextOf(td).replace(new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), to); if (next !== raw && setCellValue(td, next)) replaced++; }); panelEls.forEach(refreshPanelFormulas); refreshSaveState(); if (replaced) showToast(t("xlsx.mi_replace_done", { n: replaced }), "success"); else showToast(t("xlsx.mi_replace_none"), "info"); }; // ── open / close (same dismissal contract as the other menus, #179) ───── const openers = new Map(); const closeMenu = (btn) => { const dismiss = openers.get(btn); if (dismiss) { dismiss(); openers.delete(btn); } }; const closeAllMenus = () => [...openers.keys()].forEach(closeMenu); menuBar.querySelectorAll(".xlsx-menubtn").forEach((btn) => { const drop = document.getElementById(btn.getAttribute("aria-controls")); if (!drop) return; enableArrowNav(drop, ".xlsx-menu-item"); btn.addEventListener("click", () => { if (openers.has(btn)) { closeMenu(btn); return; } closeAllMenus(); drop.style.display = "block"; btn.setAttribute("aria-expanded", "true"); const toolbar = btn.closest(".xlsx-toolbar") || area; anchorUnder(drop, toolbar, btn); const first = drop.querySelector(".xlsx-menu-item"); if (first && typeof first.focus === "function") first.focus({ preventScroll: true }); openers.set(btn, trackDismissable(drop, { toggle: btn, onClose: () => { openers.delete(btn); drop.style.display = "none"; btn.setAttribute("aria-expanded", "false"); }, })); }); }); // Sheets accordion: one section open per level, the others collapse. menuBar.addEventListener("click", (e) => { const row = e.target.closest ? e.target.closest(".xlsx-menu-item[aria-haspopup='menu']") : null; if (!row) return; // stopImmediatePropagation, not stopPropagation: the dispatcher below // listens on the SAME element, and a section row must not be treated as // a plain command (it would close the menu it just filled). e.stopImmediatePropagation(); // A section can also declare an action (the empty « Thème » one): run it // instead of toggling, it fills the section itself. const fill = row.dataset.xlsxAction ? menuActions[row.dataset.xlsxAction] : null; if (fill) { fill(row.dataset.xlsxArg, row); return; } const sub = row.nextElementSibling; if (!sub || !sub.classList.contains("xlsx-menu-sub")) return; const wasOpen = row.getAttribute("aria-expanded") === "true"; row.parentElement.querySelectorAll(".xlsx-menu-item[aria-haspopup='menu']").forEach((other) => { if (other === row) return; other.setAttribute("aria-expanded", "false"); const otherSub = other.nextElementSibling; if (otherSub && otherSub.classList.contains("xlsx-menu-sub")) otherSub.hidden = true; }); row.setAttribute("aria-expanded", String(!wasOpen)); sub.hidden = wasOpen; }); // One delegated dispatcher for every entry of every menu. menuBar.addEventListener("click", (e) => { const item = e.target.closest ? e.target.closest("[data-xlsx-action], [data-xlsx-unavailable]") : null; if (!item) return; const labelNode = item.querySelector(".xlsx-menu-label"); const label = labelNode ? labelNode.textContent : ""; const kind = item.dataset.xlsxExport; // Any pick dismisses the menu — including an entry whose only job is // to announce it is unavailable (#185). closeAllMenus(); if (!kind && item.hasAttribute("data-xlsx-unavailable")) { showToast(t("xlsx.menu_unavailable", { item: label }), "info"); return; } if (kind) { if (kind === "print") printSheet(); else exportDocument(kind); return; } const run = menuActions[item.dataset.xlsxAction]; if (!run) { showToast(t("xlsx.menu_unavailable", { item: label }), "info"); return; } run(item.dataset.xlsxArg, item); }); } 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 = []; // Clic droit sur un EN-TÊTE (Excel / Sheets) : les commandes de taille // d'abord — le dialogue et l'ajustement automatique portent sur la colonne // ou la ligne cliquée (selectForTarget() en a déjà couvert toute la // longueur / largeur). if (editable && target.kind === "col") { items.push({ icon: "move-horizontal", label: t("xlsx.format_col_width"), onClick: () => promptColumnWidth() }); items.push({ icon: "maximize-2", label: t("xlsx.format_col_fit"), onClick: () => autoFitSelection("col") }); items.push({ separator: true }); } else if (editable && target.kind === "row") { items.push({ icon: "move-vertical", label: t("xlsx.format_row_height"), onClick: () => promptRowHeight() }); items.push({ icon: "maximize-2", label: t("xlsx.format_row_fit"), onClick: () => autoFitSelection("row") }); items.push({ separator: true }); } // #156-A5 — clipboard entries first, in the spreadsheet order. // #179 — every entry carries a representative Lucide icon. items.push({ icon: "scissors", label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() }); items.push({ icon: "copy", label: t("xlsx.copy"), onClick: () => copySelection() }); items.push({ icon: "clipboard-paste", label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() }); items.push({ separator: true }); if (editable) { items.push({ icon: "rows", label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) }); items.push({ icon: "rows", label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) }); items.push({ icon: "columns", label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) }); items.push({ icon: "columns", 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({ icon: "trash-2", 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({ icon: "trash-2", 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({ icon: "arrow-up", label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") }); items.push({ icon: "arrow-down", label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") }); } if (editable) { items.push({ separator: true }); items.push({ icon: "eraser", label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) }); } return items; }; const openGridMenu = (x, y, target, byTouch = false) => { selectForTarget(target); openContextMenu(x, y, buildContextItems(target), { byTouch }); }; 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, true); }, 500); }, { passive: true }); viewerEl.addEventListener("touchmove", cancelLongPress, { passive: true }); viewerEl.addEventListener("touchend", cancelLongPress); viewerEl.addEventListener("touchcancel", cancelLongPress); // #182 — focus a cell WITHOUT letting its focus handler collapse the current // range: the focus handler only syncs the formula bar, so the Name Box has to // be restored to the range address afterwards (otherwise a drag or a // Shift+click left `C3` in the box instead of `A1:C3`). const focusKeepingRange = (td) => { if (!td || !td.isConnected) return; suppressSelectionReset = true; td.focus(); suppressSelectionReset = false; if (selection) activeCellEl.value = rangeLabel(); }; // Drag-to-select a rectangular range + Shift+click extension. let dragAnchorTd = null; let dragActive = false; let dragExtended = false; viewerEl.addEventListener("mousedown", (e) => { // Clic droit sur la grille : le navigateur déplace le focus sur la cellule // au moment du `mousedown`. Le handler `focus` de la cellule appellerait // alors setActiveCell() et ÉCRASERAIT la sélection multiple AVANT que le // menu contextuel ne s'ouvre — les entrées Copier / Coller / Effacer ne // s'appliquaient plus qu'à la seule cellule sous le curseur. Le // `preventDefault()` annule ce déplacement de focus : la plage survit // jusqu'au choix d'une option (Google Sheets fait de même). Le // déplacement de la sélection vers une cellule HORS plage reste géré par // selectForTarget(), explicitement, à l'ouverture du menu. if (e.button === 2) { e.preventDefault(); return; } if (e.button !== 0) return; const td = e.target.closest && e.target.closest("td[data-cell]"); if (!td) return; const panel = td.closest(".xlsx-panel"); if (e.shiftKey && selection && selection.panel === panel) { e.preventDefault(); selection.focus = parseRef(cellName(td)) || selection.focus; applySelection(); // #182 — `preventDefault()` above suppresses the browser's own focus // move, so `activeTd` and the real DOM focus used to diverge: the next // click on the still-focused cell fired NO focus event and the range // stayed stuck. Focusing here keeps both in step. activeTd = td; // #182 — `preventDefault()` above suppresses the browser's own focus // move, so `activeTd` and the real DOM focus used to diverge: the next // click on the still-focused cell fired NO focus event and the range // stayed stuck. Focusing here keeps both in step — under // `suppressSelectionReset`, so the focus handler does not collapse the // range we just extended. suppressSelectionReset = true; td.focus(); suppressSelectionReset = false; formulaInput.disabled = false; formulaInput.value = barTextOf(td); if (selection) activeCellEl.value = rangeLabel(); return; } // #181-A1 — a plain click SELECTS: drop any in-cell caret/text selection // and leave edit mode, so no blinking cursor is left behind on the cell. if (activeTd && isTypingInCell(activeTd)) { commitEdit(); exitEditing(activeTd); } // #182 — a plain click ALWAYS collapses the selection to the clicked cell, // even when that cell already had the focus (no focus event fires then, // which is why a previously drawn range refused to clear). Doing it here // rather than relying on `focus` makes deselection deterministic. setActiveCell(td); 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; // #182 — no focus() here on purpose: a drag does not move the focus in // a browser either, and stealing it would collapse the range we just // drew (the cell's focus handler resets the selection to itself). // Deselection no longer depends on focus events — the `mousedown` // handler collapses the range explicitly. formulaInput.disabled = false; formulaInput.value = barTextOf(dragAnchorTd); // #182 — the Name Box keeps showing the range, not just its anchor. if (selection) activeCellEl.value = rangeLabel(); } 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 = '

Table des matières

'; } area.innerHTML = `
PDF — ${pages} pages
${tocHtml}
`; area.querySelectorAll('.pdf-toolbar .btn-action').forEach((btn) => { btn.addEventListener('click', () => { window.open(btn.dataset.pdfUrl || btn.dataset.downloadUrl, '_blank'); }); }); area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); navigatePdfToPage(area, link.getAttribute('data-page')); }); }); lucide.createIcons(); return; } // Handle Excel .xlsx — editable table view (display / edit / download) if (data.is_xlsx) { renderXlsxViewer(area, data); return; } // Handle CSV — same spreadsheet viewer, flat mode (#153 A16): the backend // renders the grid with data-cell refs and PUT …/csv/save persists edits. if (data.is_csv) { renderXlsxViewer(area, data); return; } // Handle Excalidraw — render in iframe editor if (data.is_excalidraw) { renderExcalidraw(area, data, data.vault, data.path); return; } // Handle images — dedicated zoom/pan viewer (roadmap #108-D) if (data.is_image) { renderImageViewer(area, data); return; } // Handle audio / video — native HTML5 players (roadmap #109) if (data.is_audio) { renderAudioViewer(area, data); return; } if (data.is_video) { renderVideoViewer(area, data); return; } // Handle unsupported (binary) files if (data.unsupported) { const sizeStr = data.size_bytes ? data.size_bytes < 1024 ? `${data.size_bytes} o` : data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko` : `${(data.size_bytes / 1048576).toFixed(1)} Mo` : ""; area.innerHTML = `
${escapeHtml(data.path.split("/").pop())}
${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}
${sizeStr ? `
Taille : ${sizeStr}
` : ""}
`; lucide.createIcons(); document.getElementById("unsupported-download-btn").addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; window.open(dlUrl, "_blank"); }); return; } // Breadcrumb const parts = data.path.split("/"); const breadcrumbEls = []; breadcrumbEls.push( makeBreadcrumbSpan(data.vault, () => { focusPathInSidebar(data.vault, "", { alignToTop: "center" }); }), ); let accumulated = ""; parts.forEach((part, i) => { breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")])); accumulated += (accumulated ? "/" : "") + part; const p = accumulated; if (i < parts.length - 1) { breadcrumbEls.push( makeBreadcrumbSpan(part, () => { focusPathInSidebar(data.vault, p, { alignToTop: "center" }); }), ); } else { breadcrumbEls.push( makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => { focusPathInSidebar(data.vault, data.path, { alignToTop: "center" }); }), ); } }); const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls); // Tags const tagsDiv = el("div", { class: "file-tags" }); (data.tags || []).forEach((tag) => { if (!TagFilterService.isTagFiltered(tag)) { const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]); t.addEventListener("click", () => searchByTag(tag)); tagsDiv.appendChild(t); } }); // Action buttons // Cache local au rendu courant: state.cachedRawSource global est fiable // uniquement quand openFile() initialise le fichier (il le réinitialise). // Or le contenu d'un onglet peut être rendu via renderFile() direct sans // passer par openFile() — un cache partagé transporterait le contenu du // fichier précédent (bug pretty print sur fichiers successifs). let localRaw = null; const getRaw = async () => { if (localRaw != null) return localRaw; if (state.currentVault === data.vault && state.currentPath === data.path && state.cachedRawSource) { localRaw = state.cachedRawSource; return localRaw; } const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`; const rawData = await api(rawUrl); localRaw = rawData.raw; return localRaw; }; const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]); copyBtn.addEventListener("click", async () => { try { const rawSrc = await getRaw(); if (navigator.clipboard) { await navigator.clipboard.writeText(rawSrc); } else { // Fallback for non-secure contexts const ta = document.createElement("textarea"); ta.value = rawSrc; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } copyBtn.lastChild.textContent = t('viewer.copied'); setTimeout(() => (copyBtn.lastChild.textContent = t('viewer.copy')), 1500); } catch (err) { console.error("Copy error:", err); showToast(t("viewer.copy_error"), "error"); } }); const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]); // Download raw file button (extension shown; .md label for markdown) const mdBtn = el("button", { class: "btn-action", title: t("viewer.download_file") || t("viewer.download_md") }, [icon("file-text", 14), document.createTextNode(data.extension || ".md")]); mdBtn.addEventListener("click", () => { const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`; const a = document.createElement("a"); a.href = dlUrl; a.download = data.path.split("/").pop(); document.body.appendChild(a); a.click(); document.body.removeChild(a); }); // PDF download button const pdfBtn = el("button", { class: "btn-action", title: t("viewer.download_pdf") }, [icon("file", 14), document.createTextNode("PDF")]); pdfBtn.addEventListener("click", () => { const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`; window.open(pdfUrl, "_blank"); }); // Export dropdown button (HTML / MD bundle / ePub) const exportWrap = el("div", { class: "export-dropdown", style: "position:relative;display:inline-block" }); const exportBtn = el("button", { class: "btn-action", title: t("viewer.export_title") }, [icon("download", 14), document.createTextNode(t("viewer.export"))]); const exportMenu = el("div", { class: "export-menu", style: "display:none;position:absolute;right:0;top:100%;z-index:1000;min-width:200px;padding:4px;background:var(--bg,#fff);border:1px solid #ccc;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.18)" }); const exportOptions = [ { format: "html", icon: "file-code", label: t("viewer.export_html"), ext: ".html" }, { format: "md-bundle", icon: "archive", label: t("viewer.export_md_bundle"), ext: ".zip" }, { format: "epub", icon: "book-open", label: t("viewer.export_epub"), ext: ".epub" }, ]; exportOptions.forEach((opt) => { const item = el("button", { class: "export-menu-item", style: "display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:none;background:none;cursor:pointer;border-radius:4px;text-align:left;font-size:13px" }, [ icon(opt.icon, 14), document.createTextNode(opt.label), ]); item.addEventListener("mouseenter", () => { item.style.background = "rgba(128,128,128,.15)"; }); item.addEventListener("mouseleave", () => { item.style.background = "none"; }); item.addEventListener("click", () => { exportMenu.style.display = "none"; const baseName = data.path.split("/").pop().replace(/\.[^.]+$/, "") || "document"; downloadExport(data.vault, data.path, opt.format, baseName + opt.ext); }); exportMenu.appendChild(item); }); exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block"; }); exportWrap.appendChild(exportBtn); exportWrap.appendChild(exportMenu); const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]); editBtn.addEventListener("click", () => { openEditor(data.vault, data.path); }); const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]); forgeBtn.addEventListener("click", () => { var modal = document.getElementById("editor-modal"); var bodyEl = document.getElementById("editor-body"); // Set editor state so parent buttons work state.editorVault = data.vault; state.editorPath = data.path; // #93 — Forge replaces the document read view when this file is the one // currently displayed; otherwise it falls back to the overlay. var inline = activateInlineEditor(data.vault, data.path); if (!inline) { // A stale inline session (another document) must release the area first. detachInlineEditor(); state.editorVault = data.vault; state.editorPath = data.path; } // Update header: show "Forge" brand + filename (the header travels with the // container in inline mode — query it wherever it lives, #93) var brand = queryEditor(".editor-brand"); var titleInp = document.getElementById("editor-title-input"); var fileInp = document.getElementById("editor-filename-input"); if (brand) brand.textContent = t("viewer.forge_brand"); if (titleInp) { titleInp.value = data.title || ''; titleInp.placeholder = t('forge.title_placeholder'); } if (fileInp) fileInp.value = data.path.split('/').pop(); bodyEl.innerHTML = ""; bodyEl.style.overflow = "hidden"; var iframe = document.createElement("iframe"); iframe.id = "forge-iframe"; iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now(); // #101 — allow the Forge editor's own Fullscreen button to work. iframe.setAttribute("allow", "fullscreen"); iframe.setAttribute("allowfullscreen", ""); // Inline: the iframe fills the content area; overlay: the original 82vh. iframe.style.cssText = "width:100%;height:" + (inline ? "100%" : "82vh") + ";border:none;display:block;"; bodyEl.appendChild(iframe); modal.classList.add("active"); safeCreateIcons(); }); const openNewWindowBtn = el("button", { class: "btn-action", title: t("forge.popout") }, [icon("external-link", 14), document.createTextNode(t("forge.popout_label"))]); openNewWindowBtn.addEventListener("click", () => { const theme = document.documentElement.getAttribute('data-theme') || 'dark'; const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}?theme=${theme}`; window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no"); }); const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]); tocBtn.addEventListener("click", () => { RightSidebarManager.toggle(); }); // Share button — check if already shared const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]); // Check if already shared and color the button (async () => { try { const shares = await api("/api/shares"); if (shares.some(s => s.vault === data.vault && s.path === data.path)) { shareBtn.classList.add("shared"); shareBtn.title = t('viewer.shared_doc_title'); } } catch (e) { /* ignore */ } })(); shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path)); // Bookmark button — check if already bookmarked const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]); // Check bookmark status and color the button (async () => { try { const bms = await api("/api/bookmarks"); if (Array.isArray(bms) && bms.some(b => b.vault === data.vault && b.path === data.path)) { bookmarkBtn.classList.add("active"); bookmarkBtn.title = t("viewer.bookmark_remove"); } } catch (e) { /* ignore */ } })(); bookmarkBtn.addEventListener("click", async () => { try { const res = await api("/api/bookmarks/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vault: data.vault, path: data.path, title: data.title }) }); bookmarkBtn.classList.toggle("active", res.bookmarked); bookmarkBtn.title = res.bookmarked ? t("viewer.bookmark_remove") : t("viewer.bookmark_add"); showToast(res.bookmarked ? "Ajouté aux bookmarks" : "Retiré des bookmarks", "success"); if (typeof DashboardBookmarkWidget !== "undefined") DashboardBookmarkWidget.load(); } catch (err) { showToast("Erreur: " + err.message, "error"); } }); // Frontmatter — Accent Card let fmSection = null; if (data.frontmatter && Object.keys(data.frontmatter).length > 0) { fmSection = buildFrontmatterCard(data.frontmatter); } // Content container (rendered HTML) const mdDiv = el("div", { class: "md-content", id: "file-rendered-content" }); mdDiv.innerHTML = data.html; // Raw source container (hidden initially) const rawDiv = el("div", { class: "raw-source-view", id: "file-raw-content", style: "display:none" }); // Source button toggle logic sourceBtn.addEventListener("click", async () => { const rendered = document.getElementById("file-rendered-content"); const raw = document.getElementById("file-raw-content"); if (!rendered || !raw) return; state.showingSource = !state.showingSource; if (state.showingSource) { sourceBtn.classList.add("active"); raw.textContent = await getRaw(); rendered.style.display = "none"; raw.style.display = "block"; } else { sourceBtn.classList.remove("active"); rendered.style.display = "block"; raw.style.display = "none"; } }); // Pretty print — mode lecture enrichi (numeros de ligne + coloration) // pour les fichiers texte non-markdown (code, JSON, configs, logs...) let prettyBtn = null; const isTextViewerFile = !data.is_markdown && !data.is_csv && !data.unsupported && !data.is_pdf && !data.is_image && !data.is_excalidraw; if (isTextViewerFile) { prettyBtn = el("button", { class: "btn-action", title: t("viewer.pretty_title") || "Mode formaté avec couleurs et numéros de ligne" }, [icon("sparkles", 14), document.createTextNode(t("viewer.pretty") || "Pretty")]); prettyBtn.addEventListener("click", async () => { const on = !prettyBtn.classList.contains("active"); if (on) { let rawText = ""; try { rawText = await getRaw(); } catch (_) { rawText = mdDiv.textContent || ""; } mdDiv.innerHTML = buildPrettyHtml(data, rawText); const codeEl = mdDiv.querySelector("code"); applyPrettyHighlight(codeEl, detectPrettyLang(data), rawText); prettyBtn.classList.add("active"); } else { mdDiv.innerHTML = data.html; mdDiv.querySelectorAll("pre code").forEach((block) => { if (block.querySelector(".secret-mask")) return; 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 — a block carrying a secret mask (#188) must be // left alone: highlight.js rebuilds the markup and would drop the badge. area.querySelectorAll("pre code").forEach((block) => { if (block.querySelector(".secret-mask")) return; 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); }); }); // Secret masks (#188): click copies the real value to the clipboard. // The handler is delegated (and attached once) so it survives re-renders. area.querySelectorAll(".secret-mask").forEach((mask) => { mask.title = t("viewer.secret_copy_hint"); }); if (!area.dataset.secretMaskWired) { area.dataset.secretMaskWired = "1"; area.addEventListener("click", (e) => { const mask = e.target instanceof Element ? e.target.closest(".secret-mask") : null; if (!mask || !area.contains(mask)) return; const secret = mask.getAttribute("data-secret"); if (!secret) return; const ok = copyToClipboard(secret); showToast(ok ? t("viewer.secret_copied") : t("viewer.secret_copy_failed"), ok ? "success" : "error"); }); } 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 = `
${t('common.loading')}
${QUICK_HELP_HTML}
${t('dashboard.no_bookmarks')}

${t('dashboard.pin_hint')}

${t('dashboard.no_shares')}

${t('dashboard.shared_hint')}

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