Files
ObsiGate/frontend/js/viewer.js
T
bruno 10453d8dfe
CI / lint (push) Successful in 3m1s
CI / security (push) Successful in 1m41s
CI / test (push) Successful in 4m37s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 17m40s
feat: dossier perso (#194) — vaults home-* en tête de l'arborescence, icône maison, compose de test
- frontend/js/sidebar.js : tri stable _homeFirst appliqué à la source des
  deux listes de vaults (loadVaults + soft refresh) → les vaults home-<user>
  passent en premier, arborescence et sélecteurs cohérents.
- frontend/js/viewer.js : getVaultIcon renvoie l'icône lucide "home" pour
  home-* (même safeCreateIcons / stroke currentColor que les autres).
- docker-compose.test-win.yml : montage test_home:/vaults/Home +
  OBSIGATE_HOME_ROOT=/vaults/Home pour tester la feature en local ;
  test_home/ ajouté au .gitignore.
- Tests : sidebar-filters.test.mjs +2 (icône maison, gemme violette
  inchangée), vérifié live sur l'instance 2020 (ordre + svg.lucide-home).
2026-10-10 16:09:59 -04:00

8341 lines
345 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ObsiGate — Viewer module */
import { api, AuthManager } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
import { isInlineEditorActive, queryEditor } from './editor-inline.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard, showConfirm, showPrompt } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
import { renderMermaidBlocks } from './mermaid-viewer.js';
import { renderExcalidraw } from './excalidraw-viewer.js';
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t, getLocale } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
import { openAssistant } from './ai-fab.js';
import { toggleFileChat, followFileChat } from './filechat.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 = '<div class="loading-indicator"><div class="loading-spinner"></div><div>Chargement...</div></div>';
try {
const url = `/api/file/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(filePath)}`;
const data = await api(url);
// Cache the file for offline use
if (data && !data.unsupported && data.content) {
cacheViewedFile(vaultName, filePath, data.content, data.modified);
}
renderFile(data);
} catch (err) {
// Try IndexedDB fallback
console.log('[Viewer] Network failed, trying offline cache:', err);
const cached = await getCachedFile(vaultName, filePath);
if (cached && cached.content) {
const fakeData = {
vault: vaultName,
path: filePath,
title: filePath.split('/').pop().replace(/\.md$/, ''),
content: cached.content,
html: null,
raw_size: cached.content.length,
modified: cached.modified || '',
tags: [],
offline: true
};
renderFile(fakeData);
showToast(t('viewer.loaded_from_cache'), 'info');
} else {
area.innerHTML = '<div class="welcome"><p style="color:var(--text-muted)">Impossible de charger le fichier — vous etes hors-ligne et ce fichier n\'est pas en cache.</p></div>';
}
}
}
async function renderBacklinksPanel(vault, path, container) {
try {
const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
if (!data.backlinks || data.backlinks.length === 0) return;
const panel = el("div", { class: "backlinks-panel" });
const header = el("div", { class: "backlinks-header" }, [
icon("link", 14),
document.createTextNode(` ${data.total} lien(s) entrant(s)`),
]);
panel.appendChild(header);
const list = el("div", { class: "backlinks-list" });
data.backlinks.forEach((bl) => {
const item = el("div", { class: "backlink-item" });
const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
item.appendChild(icon(getFileIcon(bl.path), 12));
item.appendChild(vaultBadge);
item.appendChild(titleEl);
item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
list.appendChild(item);
});
panel.appendChild(list);
container.appendChild(panel);
} catch (err) {
// Silently ignore — backlinks are optional
console.debug("Backlinks fetch failed:", err);
}
}
// ── Pretty print (mode lecture enrichi pour fichiers texte non-markdown) ──
// Detecte le langage depuis le HTML deja rendu par le backend (classe
// language-xxx), formate le JSON, puis genere un bloc avec gouttiere de
// numeros de ligne. La coloration est appliquee ensuite via highlight.js.
function detectPrettyLang(data) {
if (data.is_json || data.extension === ".json") return "json";
const m = /class="language-([\w-]+)/.exec(data.html || "");
return m ? m[1] : "plaintext";
}
function buildPrettyHtml(data, raw) {
let text = raw;
const lang = detectPrettyLang(data);
if (lang === "json") {
try { text = JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { /* JSON invalide: on affiche brut */ }
}
const lines = text.split("\n");
const gutter = lines.map((_, i) => `<span>${i + 1}</span>`).join("");
return `<div class="pretty-view"><div class="pp-gutter" aria-hidden="true">${gutter}</div><pre class="pp-pre"><code class="language-${lang}">${escapeHtml(text)}</code></pre></div>`;
}
// Colorateur générique de secours: utilisé quand highlight.js ne produit
// aucun token (langage plaintext — .txt/.log —, langue absente du bundle,
// ou CDN hljs indisponible). Réutilise les classes hljs existantes (le
// thème CDN colore string/comment/number/attr) + classes .pp-* maison
// pour les niveaux de log qui n'ont pas d'équivalent sémantique hljs.
const PP_TOKEN_RE = /("[^"\n]*"?|'[^'\n]*'?|`[^`\n]*`?)|(^\s*(?:#|\/\/|;)\s.*$|\s+(?:#|\/\/)\s.*$)|\b(\d+(?:\.\d+)?)\b|\b(ERROR|FATAL|CRITICAL|SEVERE|WARN(?:ING)?|INFO|DEBUG|TRACE|NOTICE)\b|^(\s*[\w.\-\/ ]+)=(?=\S)|\b(true|false|null|None|TRUE|FALSE|NULL)\b/gm;
function ppClassFor(m) {
if (m[1] !== undefined) return "hljs-string";
if (m[2] !== undefined) return "hljs-comment";
if (m[3] !== undefined) return "hljs-number";
if (m[4] !== undefined) {
const lvl = m[4];
if (/ERROR|FATAL|CRITICAL|SEVERE/.test(lvl)) return "pp-level pp-err";
if (/WARN/.test(lvl)) return "pp-level pp-warn";
if (/INFO|NOTICE/.test(lvl)) return "pp-level pp-info";
return "pp-level pp-debug";
}
if (m[5] !== undefined) return "hljs-attr";
if (m[6] !== undefined) return "hljs-literal";
return null;
}
function fallbackTokenize(text) {
let out = "";
let last = 0;
PP_TOKEN_RE.lastIndex = 0;
let m;
while ((m = PP_TOKEN_RE.exec(text)) !== null) {
if (m.index > last) out += escapeHtml(text.slice(last, m.index));
const cls = ppClassFor(m);
out += cls ? `<span class="${cls}">${escapeHtml(m[0])}</span>` : escapeHtml(m[0]);
last = m.index + (m[0].length || 1);
if (m[0].length === 0) PP_TOKEN_RE.lastIndex++; // garde anti-boucle infinie
}
out += escapeHtml(text.slice(last));
return out;
}
// Applique la coloration: hljs (avec aliases de languages) si des tokens
// sortent, sinon fallback regex generique.
function applyPrettyHighlight(codeEl, lang, text) {
safeHighlight(codeEl); // gerne les alias language-xxx inconnus de hljs
const tokens = codeEl.querySelectorAll('[class^="hljs-"]').length;
if (!tokens && text.trim()) {
codeEl.innerHTML = fallbackTokenize(text);
codeEl.classList.add("hljs");
}
}
/**
* Jump the inline PDF viewer to a specific page.
*
* The browser's built-in PDF viewer lives in an ``about:blank`` content window
* (so ``contentWindow.location.hash`` never reaches the document) **and** it
* ignores a same-document fragment navigation: changing only ``#page=N`` on the
* iframe ``src`` does not move the page. A changing query parameter forces a
* real reload, and the ``#page=N`` fragment is then honoured at load — the only
* reliable way to target a page with the native viewer.
*
* @param {HTMLElement} area - Content area containing the ``.pdf-iframe``.
* @param {string|number} page - 1-based page number from the PDF outline.
*/
export function navigatePdfToPage(area, page) {
const iframe = area && area.querySelector('.pdf-iframe');
if (!iframe || page === null || page === undefined || page === '') return;
const base = iframe.getAttribute('data-pdf-url') || iframe.src.split('#')[0];
iframe.setAttribute('data-pdf-url', base);
const sep = base.includes('?') ? '&' : '?';
iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`;
}
// ---------------------------------------------------------------------------
// Image viewer (roadmap #108-D)
// ---------------------------------------------------------------------------
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]);
const IMAGE_ZOOM_MIN = 0.1;
const IMAGE_ZOOM_MAX = 8;
let _imageViewerCleanup = null;
// BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file
// is rendered. #111 — image→image navigation now happens **in place**, so the
// viewer (and these states) is no longer destroyed between siblings.
const _imageViewerState = { lightbox: false, meta: false };
/** Clamp a zoom factor into the supported [0.1, 8] range. */
export function clampImageZoom(value) {
if (!Number.isFinite(value)) return 1;
return Math.min(IMAGE_ZOOM_MAX, Math.max(IMAGE_ZOOM_MIN, value));
}
/** True when *p* has a viewable image extension. */
export function isImagePath(p) {
const lower = (p || "").toLowerCase();
const dot = lower.lastIndexOf(".");
return dot !== -1 && IMAGE_EXTS.has(lower.slice(dot));
}
/** Build the byte-serving URL used by <img> / thumbnails. */
export function buildImageUrl(vault, path) {
return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function buildThumbUrl(vault, path, size) {
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
}
// #156-A10 — per-file undo/redo history. The stacks must outlive a re-render
// (a structure save rebuilds the whole viewer) without leaking across files, so
// they live in a small bounded module-level store keyed by vault + path.
const _xlsxHistory = new Map();
const XLSX_HISTORY_MAX_FILES = 8;
function xlsxHistory(key) {
let entry = _xlsxHistory.get(key);
if (!entry) {
entry = { undo: [], redo: [] };
_xlsxHistory.set(key, entry);
}
if (_xlsxHistory.size > XLSX_HISTORY_MAX_FILES) {
for (const k of _xlsxHistory.keys()) {
if (k !== key) { _xlsxHistory.delete(k); break; }
}
}
return entry;
}
/** Clear the per-file spreadsheet undo/redo history (tests, file close). */
export function resetXlsxHistory() {
_xlsxHistory.clear();
}
// #111 — directory listing cache. Opening / switching to another image must not
// hammer `/api/browse` (the filmstrip and the sibling list share one listing).
const _imageDirCache = new Map();
const IMAGE_DIR_CACHE_TTL = 15000;
/** List the viewable image siblings of *dir*, memoised for a short TTL. */
async function listImageSiblings(vault, dir) {
const key = `${vault}\u0000${dir}`;
const cached = _imageDirCache.get(key);
if (cached && Date.now() - cached.ts < IMAGE_DIR_CACHE_TTL) return cached.items;
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
const items = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
_imageDirCache.set(key, { items, ts: Date.now() });
return items;
}
const IMAGE_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".bmp": "image/bmp",
".ico": "image/x-icon",
};
/** Best-effort MIME type for an image path (metadata panel). */
function imageMimeFor(path) {
const lower = (path || "").toLowerCase();
const dot = lower.lastIndexOf(".");
return dot === -1 ? "" : IMAGE_MIME[lower.slice(dot)] || "";
}
function formatBytes(n) {
if (!n && n !== 0) return "";
if (n < 1024) return `${n} o`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
return `${(n / 1048576).toFixed(1)} Mo`;
}
/**
* Render a full image viewer: centered image, wheel zoom (0.1×–8×), drag pan,
* double-click reset, ←/→ navigation between siblings, thumbnail filmstrip,
* collapsible metadata panel and a full-viewport lightbox.
*/
export function renderImageViewer(area, data) {
const vault = data.vault;
// #111 — mutable current-image state: navigating swaps these in place instead
// of re-rendering the whole viewer (which used to refetch the file + listing).
let currentPath = data.path;
let currentTitle = data.title || (currentPath || "").split("/").pop();
let imgUrl = data.url || 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 () => {
if (!vault) return; // #110 — pièce jointe de chat : pas de listing voisin
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", () => {
// #110 — une pièce jointe de chat n'est pas dans un vault : URL directe.
const dlUrl = data.url || `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
window.open(dlUrl, "_blank");
});
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
metaBtn.addEventListener("click", () => {
_imageViewerState.meta = !_imageViewerState.meta;
metaPanel.hidden = !_imageViewerState.meta;
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
if (_imageViewerState.meta) renderMeta();
});
const syncLightbox = () => {
container.classList.toggle("lightbox", _imageViewerState.lightbox);
lightboxBtn.setAttribute("aria-pressed", _imageViewerState.lightbox ? "true" : "false");
};
syncLightbox();
lightboxBtn.addEventListener("click", () => {
_imageViewerState.lightbox = !_imageViewerState.lightbox;
syncLightbox();
});
img.addEventListener("load", () => {
img.style.display = "";
const errEl = stage.querySelector(".image-error");
if (errEl) errEl.remove();
if (!metaPanel.hidden) renderMeta();
});
img.addEventListener("error", () => {
img.style.display = "none";
if (!stage.querySelector(".image-error")) {
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
}
});
const onKey = (e) => {
if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); }
else if (e.key === "ArrowRight") { e.preventDefault(); go(1); }
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
else if (e.key === "0") { e.preventDefault(); resetView(); }
else if (e.key === "Escape") {
_imageViewerState.lightbox = false;
syncLightbox();
}
};
document.addEventListener("keydown", onKey);
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
area.appendChild(container);
safeCreateIcons();
applyTransform();
if (_imageViewerState.meta) renderMeta();
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
}
// #110 — Audio/video are handled by the global Now Playing controller
// (frontend/js/now-playing.js): a single shared media element is teleported
// between this inline view and a body-level dock, so playback survives tab,
// pane and page navigation. The inline branches below just hand over the
// surface; the legacy "stop on re-render" cleanup no longer applies.
export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js";
/** Audio viewer (roadmap #109-B, made persistent by #110). */
export function renderAudioViewer(area, data) {
NowPlaying.attachInline(area, data);
}
/** Video viewer (roadmap #109-C, made persistent by #110). */
export function renderVideoViewer(area, data) {
NowPlaying.attachInline(area, data);
}
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
// Cells are contenteditable; edits are collected per sheet and sent to
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
// never recalculated here.
//
// #153 A1/A4 — the read response carries `xlsx_lossy_features` (parts openpyxl
// drops on save): a banner lists them and the first save asks for an explicit
// confirmation before retrying with `force: true`. A value starting with "=" or
// "@" is stored as text unless the user turns the formula toggle on, so a typed
// `=cmd|…` cannot execute when the file is later opened in Excel.
// #153 A8 — a sheet bigger than the render caps used to be silently cut: the
// user saw a short table and no way to tell the rest of the workbook still
// existed. The backend now reports the real dimensions of every sheet, so the
// note states exactly what is hidden (and that those cells are not editable
// here — the workbook itself is untouched). A payload without those fields
// (older cache) simply shows no note.
function truncationNote(sheet) {
// The cap is the real "shown" figure, not `rows`/`cols`: those are post-trim
// (a sparse sheet renders 1x1) while the note must say how far the view
// reaches.
const cap = { rows: Number(sheet.max_rows) || 0, cols: Number(sheet.max_cols) || 0 };
const reasons = [];
if (Number(sheet.total_rows) > cap.rows) {
reasons.push(t("xlsx.truncated_rows", { shown: cap.rows, total: Number(sheet.total_rows) }));
}
if (Number(sheet.total_cols) > cap.cols) {
reasons.push(t("xlsx.truncated_cols", { shown: cap.cols, total: Number(sheet.total_cols) }));
}
if (!reasons.length) return "";
return `<div class="xlsx-truncated" role="note">
<i data-lucide="scissors" class="xlsx-truncated-icon"></i>
<div class="xlsx-warning-body">
<strong>${escapeHtml(t("xlsx.truncated_title"))}</strong>
<span>${escapeHtml(reasons.join(" "))}</span>
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.truncated_hint"))}</span>
</div>
</div>`;
}
export function renderXlsxViewer(area, data) {
// #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 <table>).
const applyGridA11y = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table) return;
table.setAttribute("role", "grid");
table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader"));
table.querySelectorAll("tbody tr").forEach((tr) => {
tr.setAttribute("role", "row");
tr.querySelectorAll("th, td").forEach((cell) => {
cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell");
});
});
};
// #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
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
: sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}${truncationNote(s)}</div>`
).join("");
const lossWarning = lossy.length
? `<div class="xlsx-warning" role="note">
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
<div class="xlsx-warning-body">
<strong>${escapeHtml(t("xlsx.lossy_title"))}</strong>
<span class="xlsx-warning-list">${lossy
.map((f) => `<span class="xlsx-warning-tag">${escapeHtml(t("xlsx.feature_" + f))}</span>`)
.join("")}</span>
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.lossy_hint"))}</span>
</div>
</div>`
: "";
// #154-A1 — status pills and the grouped command bar are built by
// buildCommandBar() (frontend/js/xlsx/command-bar.js).
area.innerHTML = `
<div class="xlsx-viewer">
<div class="xlsx-toolbar">
${menuBarHtml}
<div class="xlsx-cmdbar" role="toolbar" aria-label="${escapeHtml(t("xlsx.cmdbar_label"))}">
<span class="xlsx-toolbar-actions">${actionsHtml}</span>
</div>
<div class="xlsx-sheetbar">
<button class="btn-action xlsx-tools-toggle" id="xlsx-tools-toggle" type="button"
aria-expanded="false" title="${escapeHtml(t("xlsx.tools_toggle"))}"
aria-label="${escapeHtml(t("xlsx.tools_toggle"))}">
<i data-lucide="menu" style="width:18px;height:18px" aria-hidden="true"></i>
</button>
${tabs}
<span class="xlsx-sheetbar-end">${fileHtml}</span>
</div>
${statusBar}
<div class="xlsx-formula-bar" role="region" aria-label="${escapeHtml(t("xlsx.mi_show_formula"))}">
<span class="xlsx-editbar">
<input type="text" class="xlsx-active-cell" id="xlsx-active-cell" value=""
spellcheck="false" autocomplete="off" placeholder="A1"
aria-label="${escapeHtml(t("xlsx.name_box_label"))}"
title="${escapeHtml(t("xlsx.name_box_hint"))}" />
<span class="xlsx-edit-fx" aria-hidden="true">fx</span>
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
spellcheck="false" list="xlsx-function-list" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}"
aria-label="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
<datalist id="xlsx-function-list"></datalist>
</span>
<span class="xlsx-find-group">
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
<span class="xlsx-find-count" id="xlsx-find-count"></span>
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
</button>
</span>
</div>
</div>
${lossWarning}
<div class="xlsx-body">
<div class="xlsx-main">
<div class="xlsx-panels">${panels}</div>
</div>
<aside class="xlsx-inspector" id="xlsx-inspector" aria-hidden="true">
<div class="xlsx-inspector-resize" id="xlsx-inspector-resize" role="separator" aria-orientation="vertical" title="${escapeHtml(t("xlsx.inspector_resize"))}"></div>
<div class="xlsx-inspector-head">
<span class="xlsx-inspector-title">${escapeHtml(t("xlsx.inspector_title"))}</span>
<span class="xlsx-inspector-actions">
<button class="btn-action xlsx-inspector-ai" id="xlsx-inspector-ai" type="button" title="${escapeHtml(t("xlsx.inspector_ai"))}">
<i data-lucide="bot" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.inspector_ai_short"))}
</button>
<button class="btn-action xlsx-inspector-close" id="xlsx-inspector-close" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>
</span>
</div>
<div class="xlsx-inspector-body" id="xlsx-inspector-body"></div>
</aside>
</div>
</div>`;
// A re-render invalidates any open context menu (it targets the old DOM).
closeContextMenu();
// #189 — mobile : la barre de menus + le ruban sont repliés par défaut
// (cf. style.css ≤768px). Ce bouton, seul dans la barre des feuilles,
// déplie/replie le tout sans jamais masquer la barre de formule.
const toolsToggle = area.querySelector("#xlsx-tools-toggle");
const toolsBar = area.querySelector(".xlsx-toolbar");
if (toolsToggle && toolsBar) {
toolsToggle.addEventListener("click", () => {
const open = toolsBar.classList.toggle("xlsx-tools-open");
toolsToggle.setAttribute("aria-expanded", String(open));
toolsToggle.classList.toggle("active", open);
safeCreateIcons();
});
}
// ── #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 = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
}
});
area.querySelector("#xlsx-inspector-close").addEventListener("click", closeInspector);
// #154-A3 — the assistant is the existing global side panel; the inspector
// just gives it a contextual entry point from the spreadsheet.
area.querySelector("#xlsx-inspector-ai").addEventListener("click", () => { openAssistant(); });
// #154-A5 — the inspector is resizable (width restored per session).
const resizeHandle = area.querySelector("#xlsx-inspector-resize");
const WIDTH_KEY = "obsigate_xlsx_inspector_width";
const clampWidth = (w) => Math.min(640, Math.max(260, w));
const savedWidth = Number(localStorage.getItem(WIDTH_KEY));
if (savedWidth >= 260 && savedWidth <= 640) {
inspector.style.flexBasis = `${savedWidth}px`;
inspector.style.width = `${savedWidth}px`;
}
let resizing = false;
const onMove = (e) => {
if (!resizing) return;
const width = clampWidth(inspector.getBoundingClientRect().right - e.clientX);
inspector.style.flexBasis = `${width}px`;
inspector.style.width = `${width}px`;
};
const onUp = () => {
if (!resizing) return;
resizing = false;
document.body.style.userSelect = "";
localStorage.setItem(WIDTH_KEY, String(Math.round(inspector.getBoundingClientRect().width)));
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault();
resizing = true;
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => {
const dirty = dirtyCount() > 0;
saveBtn.disabled = !dirty;
saveBtn.classList.toggle("is-dirty", dirty);
// #154-A2 — star a sheet tab that holds unsaved edits.
panelEls.forEach((panel) => {
const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`);
if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0);
});
};
// #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept
// and the user can retry the save without losing their work.
const bannerHost = document.createElement("div");
bannerHost.className = "xlsx-banner-host";
area.querySelector(".xlsx-panels").before(bannerHost);
const showConflict = (msg) => {
bannerHost.innerHTML = "";
const banner = document.createElement("div");
banner.className = "xlsx-banner xlsx-banner-conflict";
banner.setAttribute("role", "alert");
banner.innerHTML = `<i data-lucide="git-merge" class="xlsx-banner-icon"></i>
<span class="xlsx-banner-text">${escapeHtml(msg)}</span>
<button class="btn-action xlsx-banner-retry" type="button">${escapeHtml(t("xlsx.conflict_retry"))}</button>
<button class="btn-action xlsx-banner-dismiss" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>`;
// #156-A12 — the file changed elsewhere: re-read it first so the retry
// carries the CURRENT revision instead of failing on the same stale token.
banner.querySelector(".xlsx-banner-retry").addEventListener("click", async () => {
bannerHost.innerHTML = "";
try {
const fresh = await api(
`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
);
if (fresh && fresh.xlsx_revision) revision = fresh.xlsx_revision;
} catch (_) { /* keep the previous token: the retry is refused again */ }
saveBtn.click();
});
banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => {
bannerHost.innerHTML = "";
});
bannerHost.appendChild(banner);
safeCreateIcons();
};
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
// fetched window by window from GET …/xlsx/sheet when the user reaches the
// end of a truncated sheet (scroll sentinel) or clicks « Charger la suite ».
// Appended rows reuse the exact same edit pipeline as the initial render.
const wireLazyRows = (panel) => {
const meta = sheets[Number(panel.dataset.sheet)] || {};
if (!meta.truncated) return;
const wrapper = panel.querySelector(".csv-table-wrapper");
const table = panel.querySelector(".xlsx-table tbody");
if (!wrapper || !table) return;
let offset = Number(meta.rows) || 0;
const total = Number(meta.total_rows) || 0;
let loading = false;
let done = offset >= total;
const foot = document.createElement("div");
foot.className = "xlsx-load-more";
const refreshFoot = () => {
foot.textContent = done
? ""
: `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`;
foot.classList.toggle("done", done);
};
refreshFoot();
foot.addEventListener("click", () => { if (!done) loadMore(); });
wrapper.insertAdjacentElement("afterend", foot);
const appendWindow = (win) => {
const doc = new DOMParser().parseFromString(`<table>${win.html}</table>`, "text/html");
// No `tbody` selector: the fragment embeds its own wrapper div, so the
// parse yields bare `<tr>` inside `<table>` (the rows we want) — while
// any `tbody` in the fragment belongs to the *embedded* wrapper table.
doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr));
offset = win.offset + win.rows;
done = !win.has_more;
refreshFoot();
// The new rows must behave like the initial ones: contenteditable,
// dirty tracking, cached-value tooltip, icons in the fresh footnote.
panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell);
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
// #153 A15 — the window carries its own style/merge metadata: fold it
// into the sheet meta and re-apply, so lazy rows look like the initial
// render (applySheetMeta is idempotent over already-processed cells).
const sheetMeta = sheets[Number(panel.dataset.sheet)];
if (sheetMeta) {
Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {});
Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {});
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 `<xlsx-sel-top>` 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 <tr> rows by the text of one column. Dirty cells travel
// with their row, so a sort never loses an unsaved edit.
let sortState = null; // { col, dir } on the visible panel
const applySort = (panel, col, dir) => {
const tbody = panel.querySelector(".xlsx-table tbody");
if (!tbody) return;
const rows = [...tbody.querySelectorAll("tr")];
const orderBefore = rowNums(rows);
const numeric = rows.every((tr) => {
const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`);
const v = td ? td.textContent.trim() : "";
return v === "" || !isNaN(Number(v));
});
rows.sort((a, b) => {
const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`);
const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`);
const va = ta ? ta.textContent.trim() : "";
const vb = tb ? tb.textContent.trim() : "";
const cmp = numeric
? (parseFloat(va) || 0) - (parseFloat(vb) || 0)
: va.localeCompare(vb, "fr");
return dir === "asc" ? cmp : -cmp;
});
rows.forEach((tr) => tbody.appendChild(tr));
sortState = { col, dir };
sortResetBtn.style.display = "";
// #156-A10 — the view change is undoable (the workbook stays untouched).
const orderAfter = rowNums(rows);
if (orderBefore.join() !== orderAfter.join()) {
pushHistory({ kind: "order", sheet: panelKey(panel), before: orderBefore, after: orderAfter });
}
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 <mark> by splitting the cell's first text node at
// the hit (cells are text-first; the cached-value span stays untouched).
const first = td.firstChild;
if (first && first.nodeType === 3) {
const mark = document.createElement("mark");
mark.className = "xlsx-find-hit";
mark.textContent = text.substr(pos, needle.length);
const tail = first.splitText(pos);
tail.splitText(needle.length);
td.replaceChild(mark, tail);
findMatches.push({ td, mark, panel });
}
});
});
if (!findMatches.length) {
findCount.textContent = t("xlsx.find_no_match");
return;
}
focusMatch(0);
};
/** Show the find counter, mentioning the sheets when hits span several. */
const updateFindCount = (index) => {
const count = findMatches.length;
const sheets = new Set(findMatches.map((m) => m.panel)).size;
findCount.textContent = sheets > 1
? t("xlsx.find_count_sheets", { index, count, sheets })
: t("xlsx.find_count", { index, count });
};
const focusMatch = (i) => {
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
findIndex = (i + findMatches.length) % findMatches.length;
const { td, mark, panel } = findMatches[findIndex];
mark.classList.add("xlsx-find-current");
// A hit in another sheet: activate its tab so the match becomes visible.
if (panel.style.display === "none") {
const idx = panel.dataset.sheet;
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x.dataset.sheet === idx));
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
}
// scrollIntoView is missing in JSDOM; guard it (real browsers have it).
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
updateFindCount(findIndex + 1);
};
// One input drives both the highlight (find) and the row filter (A13):
// typing narrows the sheet to the matching rows AND highlights the hits.
findInput.addEventListener("input", () => {
panelEls.forEach((p) => applyFilter(p, findInput.value));
runFind();
});
findInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); }
});
area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1));
area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1));
const caseBtn = area.querySelector("#xlsx-find-case");
caseBtn.addEventListener("click", () => {
findCase = !findCase;
caseBtn.setAttribute("aria-pressed", String(findCase));
caseBtn.classList.toggle("active", findCase);
runFind();
});
// ── #153 A14 — workbook structure menu (sheets, rows, columns) ─────────
// Every action is an explicit user gesture (prompt/confirm) and goes to
// PUT …/xlsx/structure — one locked, atomic rewrite with a backup.
const visibleSheetIndex = () =>
Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0;
/** Inverse of a structure action, or null when it cannot be replayed. */
const inverseAction = (action) => {
switch (action.op) {
case "row_insert":
return { op: "row_delete", sheet: action.sheet, at: action.at, count: action.count || 1 };
case "col_insert":
return { op: "col_delete", sheet: action.sheet, at: action.at, count: action.count || 1 };
case "sheet_rename":
return { op: "sheet_rename", from: action.to, to: action.from };
case "sheet_add":
return String(action.name || "").length && String(action.name).length <= 31
? { op: "sheet_delete", name: action.name }
: null;
case "sheet_duplicate":
return String(action.as || "").length && String(action.as).length <= 31
? { op: "sheet_delete", name: action.as }
: null;
default:
return null; // row_delete / col_delete / sheet_delete drop data
}
};
// ── 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 = '<thead><tr><th class="xlsx-corner"></th>';
for (let c = 1; c <= cols; c++) thead += `<th>${columnName(c)}</th>`;
thead += "</tr></thead>";
let body = "<tbody>";
for (let r = 1; r <= rows; r++) {
body += `<tr><th class="xlsx-rownum">${r}</th>`;
for (let c = 1; c <= cols; c++) body += `<td data-cell="${columnName(c)}${r}"></td>`;
body += "</tr>";
}
return `<div class="csv-table-wrapper"><table class="csv-table xlsx-table">${thead}${body}</table></div>`;
};
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) => `<!DOCTYPE html>
<html lang="${document.documentElement.lang || "fr"}">
<head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head>
<body><table border="1"><tbody>${rows
.map((r) => `<tr>${r.map((v) => `<td>${escapeHtml(v)}</td>`).join("")}</tr>`)
.join("")}</tbody></table></body></html>`;
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) => `<i data-lucide="${name}" style="width:15px;height:15px" aria-hidden="true"></i>`;
minibar.innerHTML =
`<button type="button" data-mini="link" title="${escapeHtml(t("xlsx.link_add"))}">${miniIcon("link")}</button>` +
`<button type="button" data-mini="comment" title="${escapeHtml(t("xlsx.comment_add"))}">${miniIcon("message-square")}</button>` +
`<button type="button" data-mini="clear" title="${escapeHtml(t("xlsx.sort_reset"))}">${miniIcon("filter")}</button>` +
`<button type="button" data-mini="sum" title="${escapeHtml(t("xlsx.sum_insert"))}">${miniIcon("sigma")}</button>`;
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) => (
`<button type="button" class="btn-action xlsx-structure-item xlsx-menu-item" role="menuitem"`
+ ` data-xlsx-action="apply-theme" data-xlsx-arg="${escapeHtml(theme.id)}">`
+ `<span class="xlsx-menu-label">${escapeHtml(theme.name || theme.id)}</span></button>`
)).join("")
: `<div class="xlsx-menu-empty">${escapeHtml(t("xlsx.menu_unavailable_hint"))}</div>`;
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();
// #190 A — an open chat panel follows the document being rendered.
followFileChat(data.vault, data.path, data.title);
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
// BUG-072 / #111 — any render that is not an image viewer starts from a clean
// viewer. Image→image navigation now happens in place (see renderImageViewer),
// so the viewer is never rebuilt between siblings.
if (!data.is_image) {
_imageViewerState.lightbox = false;
_imageViewerState.meta = false;
}
const area = getContentArea();
// #110 — if this render is about to replace the surface that currently hosts
// the shared media element, hand it back to the persistent dock first.
NowPlaying.handleRender(area, data);
// Handle PDF files — render in iframe with TOC sidebar
if (data.is_pdf) {
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`;
const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?';
const toc = data.pdf_toc || [];
let tocHtml = '';
if (toc.length > 0) {
tocHtml = '<div class="pdf-toc"><h3>Table des matières</h3><ul>';
for (const item of toc) {
const indent = (item.level - 1) * 16;
tocHtml += `<li style="padding-left:${indent}px"><a href="#" data-page="${item.page}">${escapeHtml(item.title)}</a> <span class="toc-page">p.${item.page}</span></li>`;
}
tocHtml += '</ul></div>';
}
area.innerHTML = `
<div class="pdf-viewer-container">
<div class="pdf-toolbar">
<span class="pdf-info">PDF — ${pages} pages</span>
<button class="btn-action" data-pdf-url="${pdfUrl}">
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
</button>
<button class="btn-action" data-download-url="/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>
<div class="pdf-body">
${tocHtml}
<iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
</div>
</div>`;
area.querySelectorAll('.pdf-toolbar .btn-action').forEach((btn) => {
btn.addEventListener('click', () => {
window.open(btn.dataset.pdfUrl || btn.dataset.downloadUrl, '_blank');
});
});
area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
navigatePdfToPage(area, link.getAttribute('data-page'));
});
});
lucide.createIcons();
return;
}
// Handle Excel .xlsx — editable table view (display / edit / download)
if (data.is_xlsx) {
renderXlsxViewer(area, data);
return;
}
// Handle CSV — same spreadsheet viewer, flat mode (#153 A16): the backend
// renders the grid with data-cell refs and PUT …/csv/save persists edits.
if (data.is_csv) {
renderXlsxViewer(area, data);
return;
}
// Handle Excalidraw — render in iframe editor
if (data.is_excalidraw) {
renderExcalidraw(area, data, data.vault, data.path);
return;
}
// Handle images — dedicated zoom/pan viewer (roadmap #108-D)
if (data.is_image) {
renderImageViewer(area, data);
return;
}
// Handle audio / video — native HTML5 players (roadmap #109)
if (data.is_audio) {
renderAudioViewer(area, data);
return;
}
if (data.is_video) {
renderVideoViewer(area, data);
return;
}
// Handle unsupported (binary) files
if (data.unsupported) {
const sizeStr = data.size_bytes
? data.size_bytes < 1024 ? `${data.size_bytes} o`
: data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
: `${(data.size_bytes / 1048576).toFixed(1)} Mo`
: "";
area.innerHTML = `
<div class="unsupported-file">
<i data-lucide="file" style="width:48px;height:48px"></i>
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
<button class="btn-action" id="unsupported-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
</button>
</div>`;
lucide.createIcons();
document.getElementById("unsupported-download-btn").addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
window.open(dlUrl, "_blank");
});
return;
}
// Breadcrumb
const parts = data.path.split("/");
const breadcrumbEls = [];
breadcrumbEls.push(
makeBreadcrumbSpan(data.vault, () => {
focusPathInSidebar(data.vault, "", { alignToTop: "center" });
}),
);
let accumulated = "";
parts.forEach((part, i) => {
breadcrumbEls.push(el("span", { class: "sep" }, [document.createTextNode(" / ")]));
accumulated += (accumulated ? "/" : "") + part;
const p = accumulated;
if (i < parts.length - 1) {
breadcrumbEls.push(
makeBreadcrumbSpan(part, () => {
focusPathInSidebar(data.vault, p, { alignToTop: "center" });
}),
);
} else {
breadcrumbEls.push(
makeBreadcrumbSpan(part.replace(/\.md$/i, ""), () => {
focusPathInSidebar(data.vault, data.path, { alignToTop: "center" });
}),
);
}
});
const breadcrumb = el("div", { class: "breadcrumb" }, breadcrumbEls);
// Tags
const tagsDiv = el("div", { class: "file-tags" });
(data.tags || []).forEach((tag) => {
if (!TagFilterService.isTagFiltered(tag)) {
const t = el("span", { class: "file-tag" }, [document.createTextNode(`#${tag}`)]);
t.addEventListener("click", () => searchByTag(tag));
tagsDiv.appendChild(t);
}
});
// Action buttons
// Cache local au rendu courant: state.cachedRawSource global est fiable
// uniquement quand openFile() initialise le fichier (il le réinitialise).
// Or le contenu d'un onglet peut être rendu via renderFile() direct sans
// passer par openFile() — un cache partagé transporterait le contenu du
// fichier précédent (bug pretty print sur fichiers successifs).
let localRaw = null;
const getRaw = async () => {
if (localRaw != null) return localRaw;
if (state.currentVault === data.vault && state.currentPath === data.path && state.cachedRawSource) {
localRaw = state.cachedRawSource;
return localRaw;
}
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
const rawData = await api(rawUrl);
localRaw = rawData.raw;
return localRaw;
};
const copyBtn = el("button", { class: "btn-action", title: "Copier la source" }, [icon("copy", 14), document.createTextNode("Copier")]);
copyBtn.addEventListener("click", async () => {
try {
const rawSrc = await getRaw();
if (navigator.clipboard) {
await navigator.clipboard.writeText(rawSrc);
} else {
// Fallback for non-secure contexts
const ta = document.createElement("textarea");
ta.value = rawSrc;
ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
copyBtn.lastChild.textContent = t('viewer.copied');
setTimeout(() => (copyBtn.lastChild.textContent = t('viewer.copy')), 1500);
} catch (err) {
console.error("Copy error:", err);
showToast(t("viewer.copy_error"), "error");
}
});
const sourceBtn = el("button", { class: "btn-action", title: "Voir la source" }, [icon("code", 14), document.createTextNode("Source")]);
// Download raw file button (extension shown; .md label for markdown)
const mdBtn = el("button", { class: "btn-action", title: t("viewer.download_file") || t("viewer.download_md") }, [icon("file-text", 14), document.createTextNode(data.extension || ".md")]);
mdBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
const a = document.createElement("a");
a.href = dlUrl;
a.download = data.path.split("/").pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
// PDF download button
const pdfBtn = el("button", { class: "btn-action", title: t("viewer.download_pdf") }, [icon("file", 14), document.createTextNode("PDF")]);
pdfBtn.addEventListener("click", () => {
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf?path=${encodeURIComponent(data.path)}`;
window.open(pdfUrl, "_blank");
});
// Export dropdown button (HTML / MD bundle / ePub)
const exportWrap = el("div", { class: "export-dropdown", style: "position:relative;display:inline-block" });
const exportBtn = el("button", { class: "btn-action", title: t("viewer.export_title") }, [icon("download", 14), document.createTextNode(t("viewer.export"))]);
const exportMenu = el("div", { class: "export-menu", style: "display:none;position:absolute;right:0;top:100%;z-index:1000;min-width:200px;padding:4px;background:var(--bg,#fff);border:1px solid #ccc;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.18)" });
const exportOptions = [
{ format: "html", icon: "file-code", label: t("viewer.export_html"), ext: ".html" },
{ format: "md-bundle", icon: "archive", label: t("viewer.export_md_bundle"), ext: ".zip" },
{ format: "epub", icon: "book-open", label: t("viewer.export_epub"), ext: ".epub" },
];
exportOptions.forEach((opt) => {
const item = el("button", { class: "export-menu-item", style: "display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;border:none;background:none;cursor:pointer;border-radius:4px;text-align:left;font-size:13px" }, [
icon(opt.icon, 14),
document.createTextNode(opt.label),
]);
item.addEventListener("mouseenter", () => { item.style.background = "rgba(128,128,128,.15)"; });
item.addEventListener("mouseleave", () => { item.style.background = "none"; });
item.addEventListener("click", () => {
exportMenu.style.display = "none";
const baseName = data.path.split("/").pop().replace(/\.[^.]+$/, "") || "document";
downloadExport(data.vault, data.path, opt.format, baseName + opt.ext);
});
exportMenu.appendChild(item);
});
exportBtn.addEventListener("click", (e) => {
e.stopPropagation();
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
});
exportWrap.appendChild(exportBtn);
exportWrap.appendChild(exportMenu);
const editBtn = el("button", { class: "btn-action", title: "Editer" }, [icon("edit", 14), document.createTextNode("Editer")]);
editBtn.addEventListener("click", () => {
openEditor(data.vault, data.path);
});
const forgeBtn = el("button", { class: "btn-action btn-forge", title: t("viewer.forge_title") }, [icon("hammer", 14), document.createTextNode("Forge")]);
forgeBtn.addEventListener("click", () => {
var modal = document.getElementById("editor-modal");
var bodyEl = document.getElementById("editor-body");
// Set editor state so parent buttons work
state.editorVault = data.vault;
state.editorPath = data.path;
// #93 — Forge replaces the document read view when this file is the one
// currently displayed; otherwise it falls back to the overlay.
var inline = activateInlineEditor(data.vault, data.path);
if (!inline) {
// A stale inline session (another document) must release the area first.
detachInlineEditor();
state.editorVault = data.vault;
state.editorPath = data.path;
}
// Update header: show "Forge" brand + filename (the header travels with the
// container in inline mode — query it wherever it lives, #93)
var brand = queryEditor(".editor-brand");
var titleInp = document.getElementById("editor-title-input");
var fileInp = document.getElementById("editor-filename-input");
if (brand) brand.textContent = t("viewer.forge_brand");
if (titleInp) { titleInp.value = data.title || ''; titleInp.placeholder = t('forge.title_placeholder'); }
if (fileInp) fileInp.value = data.path.split('/').pop();
bodyEl.innerHTML = "";
bodyEl.style.overflow = "hidden";
var iframe = document.createElement("iframe");
iframe.id = "forge-iframe";
iframe.src = "/editor-poc?vault=" + encodeURIComponent(data.vault) + "&path=" + encodeURIComponent(data.path) + "&_ts=" + Date.now();
// #101 — allow the Forge editor's own Fullscreen button to work.
iframe.setAttribute("allow", "fullscreen");
iframe.setAttribute("allowfullscreen", "");
// Inline: the iframe fills the content area; overlay: the original 82vh.
iframe.style.cssText = "width:100%;height:" + (inline ? "100%" : "82vh") + ";border:none;display:block;";
bodyEl.appendChild(iframe);
modal.classList.add("active");
safeCreateIcons();
});
const openNewWindowBtn = el("button", { class: "btn-action", title: t("forge.popout") }, [icon("external-link", 14), document.createTextNode(t("forge.popout_label"))]);
openNewWindowBtn.addEventListener("click", () => {
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
const popoutUrl = `/popout/${encodeURIComponent(data.vault)}/${encodeURIComponent(data.path)}?theme=${theme}`;
window.open(popoutUrl, `popout_${data.vault}_${data.path.replace(/[^a-zA-Z0-9]/g, "_")}`, "width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no");
});
const tocBtn = el("button", { class: "btn-action", id: "toc-toggle-btn", title: "Afficher/Masquer le sommaire" }, [icon("list", 14), document.createTextNode("TOC")]);
tocBtn.addEventListener("click", () => {
RightSidebarManager.toggle();
});
// Share button — check if already shared
const shareBtn = el("button", { class: "btn-action btn-share", title: "Partager ce document" }, [icon("share-2", 14), document.createTextNode("Partager")]);
// Check if already shared and color the button
(async () => {
try {
const shares = await api("/api/shares");
if (shares.some(s => s.vault === data.vault && s.path === data.path)) {
shareBtn.classList.add("shared");
shareBtn.title = t('viewer.shared_doc_title');
}
} catch (e) { /* ignore */ }
})();
shareBtn.addEventListener("click", () => openShareDialog(data.vault, data.path));
// Bookmark button — check if already bookmarked
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
// #169 — Chat intégré par fichier (panneau latéral)
const chatBtn = el("button", { class: "btn-action btn-chat", title: t("chat.button_title") }, [icon("message-square", 14), document.createTextNode(t("chat.button"))]);
chatBtn.addEventListener("click", () => {
toggleFileChat(data.vault, data.path, data.title);
});
// 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, chatBtn);
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) {
// Dossier personnel (#194) : icône maison, même style lucide que le reste.
if (typeof vaultName === "string" && vaultName.startsWith("home-")) {
return icon("home", size);
}
const v = state.allVaults.find((val) => val.name === vaultName);
const type = v ? v.type : "VAULT";
if (type === "DIR") {
const i = icon("folder", size);
i.style.color = "#eab308"; // yellow tint
return i;
} else {
const purple = "#8b5cf6";
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("xmlns", svgNS);
svg.setAttribute("width", size);
svg.setAttribute("height", size);
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", purple);
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.classList.add("icon");
const path1 = document.createElementNS(svgNS, "path");
path1.setAttribute("d", "M6 3h12l4 6-10 12L2 9z");
const path2 = document.createElementNS(svgNS, "path");
path2.setAttribute("d", "M11 3 8 9l4 12");
const path3 = document.createElementNS(svgNS, "path");
path3.setAttribute("d", "M12 21l4-12-3-6");
const path4 = document.createElementNS(svgNS, "path");
path4.setAttribute("d", "M2 9h20");
svg.appendChild(path1);
svg.appendChild(path2);
svg.appendChild(path3);
svg.appendChild(path4);
return svg;
}
}
function makeBreadcrumbSpan(text, onClick) {
const s = document.createElement("span");
s.textContent = text;
if (onClick) {
s.addEventListener("click", async (event) => {
event.preventDefault();
if (s.dataset.busy === "true") return;
s.dataset.busy = "true";
s.style.pointerEvents = "none";
try {
await onClick(event);
} finally {
s.dataset.busy = "false";
s.style.pointerEvents = "";
}
});
}
return s;
}
export function appendHighlightedText(container, text, query, caseSensitive) {
container.textContent = "";
if (!query) {
container.appendChild(document.createTextNode(text));
return;
}
const source = caseSensitive ? text : text.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
let start = 0;
let index = source.indexOf(needle, start);
if (index === -1) {
container.appendChild(document.createTextNode(text));
return;
}
while (index !== -1) {
if (index > start) {
container.appendChild(document.createTextNode(text.slice(start, index)));
}
const mark = el("mark", { class: "filter-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
container.appendChild(mark);
start = index + query.length;
index = source.indexOf(needle, start);
}
if (start < text.length) {
container.appendChild(document.createTextNode(text.slice(start)));
}
}
export function highlightSearchText(container, text, query, caseSensitive) {
container.textContent = "";
if (!query || !text) {
container.appendChild(document.createTextNode(text || ""));
return;
}
const source = caseSensitive ? text : text.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
let start = 0;
let index = source.indexOf(needle, start);
if (index === -1) {
container.appendChild(document.createTextNode(text));
return;
}
while (index !== -1) {
if (index > start) {
container.appendChild(document.createTextNode(text.slice(start, index)));
}
const mark = el("mark", { class: "search-highlight" }, [document.createTextNode(text.slice(index, index + query.length))]);
container.appendChild(mark);
start = index + query.length;
index = source.indexOf(needle, start);
}
if (start < text.length) {
container.appendChild(document.createTextNode(text.slice(start)));
}
}
// #158 — the static « Raccourcis & Astuces » block lives in index.html inside
// #dashboard-panel-stats; showWelcome() rebuilds #dashboard-home from a
// template that omits it, so the block disappeared after the first rebuild.
// Capture it once, at module load (before any rebuild can destroy it).
const QUICK_HELP_HTML = (() => {
if (typeof document === "undefined") return "";
const qh = document.getElementById("quick-help");
return qh ? qh.outerHTML : "";
})();
export function showWelcome() {
hideProgressBar();
// #158 — the dashboard takes over the content area: drop the active-tab
// marker so clicking a tab re-renders it instead of early-returning.
if (window.TabManager && window.TabManager.deactivate) window.TabManager.deactivate();
// Restore or rebuild the dashboard with tabbed sections
const area = getContentArea();
// #110 — keep playing media alive if the dashboard replaces its surface.
NowPlaying.handleRender(area, null);
const home = document.getElementById("dashboard-home");
if (area && !home) {
area.innerHTML = `
<div id="dashboard-home" class="dashboard-home" role="region" aria-label="Tableau de bord">
<!-- Dashboard Tabs -->
<div class="dashboard-tabs">
<button class="dashboard-tab active" data-tab="stats" data-i18n="dashboard.stats">
<i data-lucide="bar-chart-3" style="width:14px;height:14px"></i> ${t('dashboard.stats')}
</button>
<button class="dashboard-tab" data-tab="bookmarks" data-i18n="dashboard.bookmarks">
<i data-lucide="bookmark" style="width:14px;height:14px"></i> ${t('dashboard.bookmarks')}
</button>
<button class="dashboard-tab" data-tab="recent" data-i18n="dashboard.tab_recent">
<i data-lucide="clock" style="width:14px;height:14px"></i> ${t('dashboard.tab_recent')}
</button>
<button class="dashboard-tab" data-tab="shared" data-i18n="dashboard.tab_shared">
<i data-lucide="share-2" style="width:14px;height:14px"></i> ${t('dashboard.tab_shared')}
</button>
</div>
<!-- Stats Panel -->
<div id="dashboard-panel-stats" class="dashboard-panel active">
<div id="dashboard-stats-grid" class="dashboard-stats-grid">
<div class="dashboard-stats-loading" data-i18n="common.loading">${t('common.loading')}</div>
</div>
<div id="dashboard-conflicts-container" style="margin-top:16px"></div>
${QUICK_HELP_HTML}
</div>
<!-- Bookmarks Panel -->
<div id="dashboard-panel-bookmarks" class="dashboard-panel">
<div id="dashboard-bookmarks-grid" class="dashboard-recent-grid"></div>
<div id="dashboard-bookmarks-empty" class="dashboard-recent-empty">
<i data-lucide="pin"></i>
<span data-i18n="dashboard.no_bookmarks">${t('dashboard.no_bookmarks')}</span>
<p data-i18n="dashboard.pin_hint">${t('dashboard.pin_hint')}</p>
</div>
</div>
<!-- Recent Panel -->
<div id="dashboard-panel-recent" class="dashboard-panel">
<div class="dashboard-header">
<div class="dashboard-title-row">
<span id="dashboard-count" class="dashboard-badge"></span>
</div>
</div>
<div id="dashboard-recent-grid" class="dashboard-recent-grid"></div>
<div id="dashboard-loading" class="dashboard-loading">
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
<div class="skeleton-card"></div><div class="skeleton-card"></div><div class="skeleton-card"></div>
</div>
<div id="dashboard-recent-empty" class="dashboard-recent-empty hidden">
<i data-lucide="inbox"></i>
<span data-i18n="dashboard.no_recent">${t('dashboard.no_recent')}</span>
<p data-i18n="dashboard.recent_hint">${t('dashboard.recent_hint')}</p>
</div>
</div>
<!-- Shared Panel -->
<div id="dashboard-panel-shared" class="dashboard-panel">
<div id="dashboard-shared-grid" class="dashboard-recent-grid"></div>
<div id="dashboard-shared-empty" class="dashboard-recent-empty">
<i data-lucide="share-2"></i>
<span data-i18n="dashboard.no_shares">${t('dashboard.no_shares')}</span>
<p data-i18n="dashboard.shared_hint">${t('dashboard.shared_hint')}</p>
</div>
</div>
</div>`;
// Re-initialize widgets and dashboard tabs
if (typeof DashboardRecentWidget !== "undefined") {
DashboardRecentWidget.init();
}
initDashboardTabs();
safeCreateIcons();
} else if (home) {
// Dashboard already exists, show it with default tab
home.style.display = "";
// Reset tabs to default
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
const defaultTab = document.querySelector('.dashboard-tab[data-tab="stats"]');
const defaultPanel = document.getElementById("dashboard-panel-stats");
if (defaultTab) defaultTab.classList.add("active");
if (defaultPanel) defaultPanel.classList.add("active");
}
// Load all widgets (they handle missing elements gracefully)
if (typeof DashboardStatsWidget !== "undefined") {
DashboardStatsWidget.load();
}
if (typeof DashboardConflictsWidget !== "undefined") {
DashboardConflictsWidget.load();
}
if (typeof DashboardRecentWidget !== "undefined") {
DashboardRecentWidget.load(state.selectedContextVault);
}
if (typeof DashboardBookmarkWidget !== "undefined") {
DashboardBookmarkWidget.load(state.selectedContextVault);
}
if (typeof DashboardSharedWidget !== "undefined") {
DashboardSharedWidget.load();
}
// Load saved searches sidebar
loadSavedSearches();
}
export async function loadSavedSearches() {
const list = document.getElementById("saved-searches-list");
const empty = document.getElementById("saved-searches-empty");
if (!list) return;
try {
const searches = await api("/api/saved-searches");
// Initialise la barre de filtres même quand la liste est vide
// (sinon les pills n'ont pas de listener au clic).
_initSavedFilter();
if (!searches.length) {
list.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
list.innerHTML = searches.map(s => {
// Detect directory-based saved search (empty query + include_paths)
var isDirSearch = (!s.query || s.query.trim() === '') && s.include_paths;
var displayQuery = isDirSearch ? (s.include_paths || 'Dossier') : escapeHtml(s.query);
// Icon: folder-search for directories, search for regular queries
var typeIcon = isDirSearch
? '<i data-lucide="folder-search" style="width:14px;height:14px;color:var(--accent)" class="saved-search-type-icon"></i>'
: '<i data-lucide="search" style="width:14px;height:14px;color:var(--text-muted)" class="saved-search-type-icon"></i>';
const badges = [];
if (s.case_sensitive) badges.push('<span class="search-filter-badge">Aa</span>');
if (s.whole_word) badges.push('<span class="search-filter-badge">wd</span>');
if (s.regex) badges.push('<span class="search-filter-badge">.*</span>');
const pathFilters = [];
if (s.include_paths) pathFilters.push(`<span class="saved-search-path" title="Inclure: ${escapeHtml(s.include_paths)}">📥 ${escapeHtml(s.include_paths)}</span>`);
if (s.exclude_paths) pathFilters.push(`<span class="saved-search-path" title="Exclure: ${escapeHtml(s.exclude_paths)}">📤 ${escapeHtml(s.exclude_paths)}</span>`);
const vaultStr = s.vault && s.vault !== "all" ? `<span class="saved-search-vault">📁 ${escapeHtml(s.vault)}</span>` : "";
return `
<div class="saved-search-item${isDirSearch ? ' saved-search-dir' : ''}" data-type="${isDirSearch ? 'directory' : 'search'}">
<div class="saved-search-query">${displayQuery}</div>
<div class="saved-search-meta">
<span class="saved-search-badges">${badges.join("")} ${vaultStr}</span>
${typeIcon}
</div>
${pathFilters.length ? '<div class="saved-search-filters">' + pathFilters.join(" ") + '</div>' : ""}
<button class="saved-search-delete" data-id="${s.id}" title="Supprimer">✕</button>
</div>
`}).join("");
// Apply saved filter
_applySavedFilter();
// Set up filter bar (once)
_initSavedFilter();
list.querySelectorAll(".saved-search-item").forEach(item => {
item.addEventListener("click", (e) => {
if (e.target.classList.contains("saved-search-delete")) return;
const idx = Array.from(list.children).indexOf(item);
const s = searches[idx];
if (!s) return;
// Apply the saved search
const input = document.getElementById("search-input");
if (input) input.value = s.query;
state.searchCaseSensitive = s.case_sensitive || false;
state.searchWholeWord = s.whole_word || false;
state.searchRegex = s.regex || false;
if (typeof _updateToggleUI === "function") _updateToggleUI();
if (s.include_paths) {
const incl = document.getElementById("search-include-input");
if (incl) incl.value = s.include_paths;
}
if (s.exclude_paths) {
const excl = document.getElementById("search-exclude-input");
if (excl) excl.value = s.exclude_paths;
}
// Execute the search — suppress dropdown from appearing
AutocompleteDropdown.hide();
AutocompleteDropdown._suppressNext = true;
const vault = s.vault || "all";
if (input) { input.dispatchEvent(new Event("input")); }
clearTimeout(state.searchTimeout);
state.advancedSearchOffset = 0;
performAdvancedSearch(s.query, vault, null);
});
});
list.querySelectorAll(".saved-search-delete").forEach(b => b.addEventListener("click", async (e) => {
e.stopPropagation();
await api(`/api/saved-searches/${b.dataset.id}`, { method: "DELETE" });
loadSavedSearches();
}));
safeCreateIcons();
} catch (err) { /* silently ignore */ }
}
export function showLoading() {
const area = getContentArea();
area.innerHTML = `
<div class="loading-indicator">
<div class="loading-spinner"></div>
<div>Recherche en cours...</div>
</div>`;
showProgressBar();
}
export function showProgressBar() {
const bar = document.getElementById("search-progress-bar");
if (bar) bar.classList.add("active");
}
export function hideProgressBar() {
const bar = document.getElementById("search-progress-bar");
if (bar) bar.classList.remove("active");
}
function goHome() {
const searchInput = document.getElementById("search-input");
if (searchInput) searchInput.value = "";
document.querySelectorAll(".tree-item.active").forEach((el) => el.classList.remove("active"));
state.currentVault = null;
state.currentPath = null;
state.showingSource = false;
state.cachedRawSource = null;
closeMobileSidebar();
showWelcome();
}
// ---------------------------------------------------------------------------
// SSE Client — IndexUpdateManager
// ---------------------------------------------------------------------------
const IndexUpdateManager = (() => {
let eventSource = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let recentEvents = [];
const MAX_RECENT_EVENTS = 20;
let connectionState = "disconnected"; // disconnected | connecting | connected
function connect() {
if (eventSource) {
eventSource.close();
}
connectionState = "connecting";
_updateBadge();
eventSource = new EventSource("/api/events");
eventSource.addEventListener("connected", (e) => {
connectionState = "connected";
reconnectDelay = 1000;
_updateBadge();
});
eventSource.addEventListener("index_updated", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_updated", data);
_onIndexUpdated(data);
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_reloaded", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_reloaded", data);
_onIndexReloaded(data);
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("vault_added", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("vault_added", data);
showToast(`Vault "${data.vault}" ajouté (${data.stats.file_count} fichiers)`, "info");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("vault_removed", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("vault_removed", data);
showToast(`Vault "${data.vault}" supprimé`, "info");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_start", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_start", data);
connectionState = "syncing";
_updateBadge();
showToast(`Indexation démarrée (${data.total_vaults} vaults)`, "info");
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_progress", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_progress", data);
connectionState = "syncing";
_updateBadge();
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.addEventListener("index_complete", (e) => {
try {
const data = JSON.parse(e.data);
_addEvent("index_complete", data);
connectionState = "connected";
_updateBadge();
showToast(`Indexation terminée (${data.total_files} fichiers)`, "success");
loadVaults();
loadTags();
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
eventSource.close();
eventSource = null;
_scheduleReconnect();
};
}
function _scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
}
function _addEvent(type, data) {
recentEvents.unshift({
type,
data,
timestamp: new Date().toISOString(),
});
if (recentEvents.length > MAX_RECENT_EVENTS) {
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
}
}
async function _onIndexUpdated(data) {
// Brief syncing state
connectionState = "syncing";
_updateBadge();
const n = data.total_changes || 0;
const vaults = (data.vaults || []).join(", ");
// Toast removed: silent auto-indexing — no notification needed
// Refresh sidebar and tags if affected vault matches current context
const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
if (affectsCurrentVault) {
try {
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
// Refresh current file if it was updated
if (state.currentVault && state.currentPath) {
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
if (changed) {
openFile(state.currentVault, state.currentPath);
}
}
} catch (err) {
console.error("Error refreshing after index update:", err);
}
}
// Refresh recent tab if it is active
if (state.activeSidebarTab === "recent") {
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
async function _onIndexReloaded(data) {
connectionState = "syncing";
_updateBadge();
showToast(t("sync.index_reloaded"), "info");
try {
await Promise.all([loadVaults(), loadTags()]);
} catch (err) {
console.error("Error refreshing after full reload:", err);
}
setTimeout(() => {
connectionState = "connected";
_updateBadge();
}, 1500);
}
function _updateBadge() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.className = "sync-badge sync-badge--" + connectionState;
const labels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Synchronisé",
syncing: t("viewer.syncing"),
};
badge.title = labels[connectionState] || connectionState;
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
connectionState = "disconnected";
_updateBadge();
}
function getState() {
return connectionState;
}
function getRecentEvents() {
return recentEvents;
}
return { connect, disconnect, getState, getRecentEvents };
})();
function initSyncStatus() {
const badge = document.getElementById("sync-badge");
if (!badge) return;
badge.addEventListener("click", (e) => {
e.stopPropagation();
toggleSyncPanel();
});
IndexUpdateManager.connect();
}
function toggleSyncPanel() {
let panel = document.getElementById("sync-panel");
if (panel) {
panel.remove();
return;
}
// Auto reconnect if disconnected when user opens the panel
if (IndexUpdateManager.getState() === "disconnected") {
IndexUpdateManager.connect();
}
panel = document.createElement("div");
panel.id = "sync-panel";
panel.className = "sync-panel";
_renderSyncPanel(panel);
document.body.appendChild(panel);
// Close on outside click
setTimeout(() => {
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
}, 0);
}
function _closeSyncPanelOutside(e) {
const panel = document.getElementById("sync-panel");
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
panel.remove();
}
}
function _renderSyncPanel(panel) {
const state = IndexUpdateManager.getState();
const events = IndexUpdateManager.getRecentEvents();
const stateLabels = {
disconnected: "Déconnecté",
connecting: "Connexion...",
connected: "Connecté",
syncing: "Synchronisation...",
};
let html = `<div class="sync-panel__header">
<span class="sync-panel__title">Synchronisation</span>
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
</div>`;
if (events.length === 0) {
html += `<div class="sync-panel__empty">Aucun événement récent</div>`;
} else {
html += `<div class="sync-panel__events">`;
events.slice(0, 10).forEach((ev) => {
const time = new Date(ev.timestamp).toLocaleTimeString();
const typeLabels = {
index_updated: t("viewer.index_updated"),
index_reloaded: "Rechargement",
vault_added: t("sync.vault_added_label"),
vault_removed: t("sync.vault_removed_label"),
index_start: t("viewer.index_start"),
index_progress: t("sync.vault_indexed_label"),
index_complete: "Indexation tech.",
};
const label = typeLabels[ev.type] || ev.type;
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
if (ev.type === "index_progress") detail = `${ev.data.vault} (${ev.data.files} fichiers)`;
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
html += `<div class="sync-panel__event">
<span class="sync-panel__event-type">${label}</span>
<span class="sync-panel__event-detail">${detail}</span>
<span class="sync-panel__event-time">${time}</span>
</div>`;
});
html += `</div>`;
}
}
// ── Saved searches filter ──
var _savedFilterType = 'all';
var _savedFilterInitDone = false;
var _savedQuery = '';
function _savedNorm(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function _initSavedFilter() {
if (_savedFilterInitDone) return;
var bar = document.getElementById('saved-filter-bar');
if (!bar) return;
bar.querySelectorAll('.saved-filter-pill').forEach(function(btn) {
btn.addEventListener('click', function() {
bar.querySelectorAll('.saved-filter-pill').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
_savedFilterType = btn.dataset.filter;
_applySavedFilter();
});
});
_savedFilterInitDone = true;
}
/** #99 — Filter the saved-searches list by the global sidebar filter input. */
export function filterSavedSearches(query) {
_savedQuery = (query || '').trim();
_applySavedFilter();
}
function _applySavedFilter() {
var items = document.querySelectorAll('.saved-search-item');
var list = document.getElementById('saved-searches-list');
var q = _savedNorm(_savedQuery);
var hasVisible = false;
items.forEach(function(item) {
var type = item.dataset.type;
var typeOk = _savedFilterType === 'all' || type === _savedFilterType;
var textOk = !q || _savedNorm(item.textContent).includes(q);
var show = typeOk && textOk;
item.style.display = show ? '' : 'none';
if (show) hasVisible = true;
});
// Remove a previous "no match" hint before deciding whether to add one.
if (list) {
var prev = list.querySelector('.sidebar-filter-empty');
if (prev) prev.remove();
}
// Show/hide empty state
var empty = document.getElementById('saved-searches-empty');
if (empty) empty.style.display = hasVisible ? 'none' : '';
// Explain an empty result caused by the global search rather than by an
// actually empty saved-searches list.
if (!hasVisible && q && list && items.length) {
var hint = document.createElement('div');
hint.className = 'sidebar-filter-empty';
hint.textContent = t('sidebar.no_results');
list.appendChild(hint);
}
}