`;
}
export function renderXlsxViewer(area, data) {
// #184 — `area` outlives a re-render, so every listener bound to it must be
// dropped first. The formula-link click handler below closes over the whole
// grid (panelEls, activeTd, selection): without this teardown each re-render
// kept the previous grid alive — opening or saving a spreadsheet a few times
// grew the tab's heap without bound.
if (typeof area._xlsxDropFormulaLinkClick === "function") {
try { area._xlsxDropFormulaLinkClick(); } catch (_) { /* ignore */ }
area._xlsxDropFormulaLinkClick = null;
}
const sheets = data.xlsx_sheets || [];
const lossy = data.xlsx_lossy_features || [];
// #153 A16 — mode flags: a .csv rides the same viewer with a flat
// structure (no tabs/merges/freeze to wire) and its own save endpoint;
// .xls/.ods are served read-only (no editing, no structure ops).
const isCsv = Boolean(data.is_csv);
const readOnly = Boolean(data.xlsx_readonly);
// Session-scoped state: once the lossy write is confirmed, the rest of the
// session saves without asking again (never persisted — a confirmation is
// per workbook, not a global preference).
let lossyConfirmed = false;
let allowFormula = false;
// #156-A12 — revision of the file as last read or written. It rides every
// write as `if_match`: a write on a file that changed elsewhere is refused
// with 409 `conflict` instead of silently overwriting the other writer.
let revision = data.xlsx_revision || null;
// #154-A1 — the command bar is grouped (Formules · Insertion · Vue · Fichier)
// and the sheet tabs are ALWAYS rendered (a single sheet used to show no tab
// at all, hiding the fact that a workbook can hold several). An editable
// workbook also gets an explicit “+” button next to the tabs.
const editable = !isCsv && !readOnly;
// #156-A10 — the undo/redo stacks are kept per file (module-level store).
const historyKey = `${data.vault || ""}\u0000${data.path || ""}`;
// #154-A5 — the shell markup (tabs + grouped command bar + status pills) is
// built by frontend/js/xlsx/command-bar.js.
const { tabs, actionsHtml, statusBar, fileHtml } = buildCommandBar({ sheets, isCsv, readOnly, editable });
// #185 — the Google Sheets menu bar (Fichier · Modifier · Affichage ·
// Insertion · Format · Données · Outils · Gemini · Extensions · Aide) sits
// ABOVE the ribbon, and every entry's action is resolved further down by
// `menuActions()`. Built here so it is a pure string like the ribbon.
const menuBarHtml = buildMenuBar({ editable, readOnly });
// #153 A15 — inline styles (bold/italic/colors from the file itself), merged
// ranges and the freeze anchor are applied AFTER the panels are in the DOM
// (the HTML string alone cannot carry them: col/row spans need the table).
/** Comment marker of one cell (empty span: text extraction unaffected). */
const decorateComments = (td) => {
const panel = td.closest ? td.closest(".xlsx-panel") : null;
const meta = sheets[Number(panel && panel.dataset ? panel.dataset.sheet : NaN)] || {};
const text = (meta.comments || {})[td.dataset.cell];
const has = td.querySelector ? td.querySelector(".xlsx-comment-dot") : null;
if (text && !has) {
const dot = document.createElement("span");
dot.className = "xlsx-comment-dot";
dot.title = text;
td.appendChild(dot);
} else if (!text && has) {
has.remove();
}
};
const applySheetMeta = (panel) => {
const meta = sheets[Number(panel.dataset.sheet)] || {};
const styleMap = meta.styles || {};
const alignMap = meta.aligns || {};
const comments = meta.comments || {};
// #184 — the padded grid holds ~26 000 cells; only the ones the workbook
// actually styles are worth touching (the clones/queries below are not).
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
const ref = td.dataset.cell;
const style = styleMap[ref];
const align = alignMap[ref];
if (!style && !align && !comments[ref]) return;
if (style) td.setAttribute("style", style);
if (align) td.style.textAlign = align;
decorateComments(td);
});
// Explicit column widths / row heights (col_width & row_height ops):
// a colgroup carries the widths (units → px), rows take their height.
const table = panel.querySelector(".xlsx-table");
if (table) {
const widths = meta.colwidths || {};
let cg = table.querySelector("colgroup.xlsx-cols");
if (!cg) {
cg = document.createElement("colgroup");
cg.className = "xlsx-cols";
table.prepend(cg);
}
cg.replaceChildren();
const heads = table.querySelectorAll("thead th");
heads.forEach((th, i) => {
const col = document.createElement("col");
if (i === 0) {
col.style.width = "44px"; // row-number gutter (matches frozen offset)
} else {
const w = widths[columnName(i)];
if (w) col.style.width = `${Math.round(Number(w) * 7)}px`;
}
cg.appendChild(col);
});
const heights = meta.rowheights || {};
table.querySelectorAll("tbody tr").forEach((tr) => {
const h = heights[String(rowNumOf(tr))];
if (h) tr.style.height = `${Math.round(Number(h) * 96 / 72)}px`;
else tr.style.height = "";
});
}
// Merged ranges: span the anchor cell over the range and hide the covered
// cells (rowSpan/colSpan survive a re-render; hidden cells are skipped by
// the save collector because their text was already blank in openpyxl).
// Reset first: dynamic updates (unmerge, structure surgery) must also be
// able to REMOVE spans and reveals. Filtered rows hide TRs, never TDs, so
// clearing td-level display is safe.
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
td.rowSpan = 1;
td.colSpan = 1;
if (td.style.display === "none") td.style.display = "";
});
(meta.merges || []).forEach((range) => {
const [from, to] = range.split(":");
const anchor = panel.querySelector(`td[data-cell="${from}"]`);
const last = panel.querySelector(`td[data-cell="${to}"]`);
if (!anchor || !last || anchor === last) return;
const a = parseRef(from);
const b = parseRef(to);
if (!a || !b) return;
anchor.rowSpan = Math.max(1, b.row - a.row + 1);
anchor.colSpan = Math.max(1, b.col - a.col + 1);
// Hide every covered cell of the block.
for (let r = a.row; r <= b.row; r++) {
for (let c = a.col; c <= b.col; c++) {
if (r === a.row && c === a.col) continue;
const covered = panel.querySelector(`td[data-cell="${columnName(c)}${r}"]`);
if (covered) covered.style.display = "none";
}
}
});
// Freeze panes: sticky the rows/cols left of and above the anchor.
// Reset first so unfreezing clears the classes without a re-render.
panel.querySelectorAll(".xlsx-frozen-row").forEach((tr) => tr.classList.remove("xlsx-frozen-row"));
panel.querySelectorAll(".xlsx-frozen-col").forEach((td) => td.classList.remove("xlsx-frozen-col"));
const freeze = meta.freeze;
if (freeze && freeze !== "A1") {
const f = parseRef(freeze);
if (f) {
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
const ref = tr.querySelector("td")?.dataset.cell || "";
const p = parseRef(ref);
if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row");
});
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
const p = parseRef(td.dataset.cell);
if (p && p.col < f.col) td.classList.add("xlsx-frozen-col");
});
}
}
};
// #154-A4 — ARIA grid semantics (the backend renders a plain
).
const applyGridA11y = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table) return;
table.setAttribute("role", "grid");
table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader"));
table.querySelectorAll("tbody tr").forEach((tr) => {
tr.setAttribute("role", "row");
tr.querySelectorAll("th, td").forEach((cell) => {
cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell");
});
});
};
// #184 — every sheet is laid out like a real spreadsheet: the grid always
// shows columns A → Z and 1000 rows, and the empty cells are KEPT (they are
// ordinary editable cells, not decoration). The grid is expanded for the sheet
// on screen only — expanding every tab at once would multiply this by the
// number of sheets, and the expansion is rebuilt when a tab is activated.
const GRID_MIN_COLS = 26; // A … Z — the opening column bound, always materialised
const GRID_MIN_ROWS = 1000; // #184 — how far the grid may grow, on demand
const GRID_CHUNK_ROWS = 100; // rows materialised per extension step
/**
* Pad a panel's table up to GRID_MIN_COLS (columns).
* Idempotent, and a no-op once the table already reaches that bound.
*/
const expandPanelGrid = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table) return;
const headRow = table.querySelector("thead tr");
const tbody = table.querySelector("tbody");
if (!headRow || !tbody) return;
if (panel.dataset.gridColsPadded === "1") return;
panel.dataset.gridColsPadded = "1";
// Columns: add the missing letters after the last one.
const firstCol = columnName(1);
const lastLetter = (() => {
const ths = headRow.querySelectorAll("th:not(.xlsx-corner)");
return ths.length ? ths[ths.length - 1].textContent.trim() : "";
})();
const have = colToIndex(lastLetter) || 0;
if (have < GRID_MIN_COLS) {
for (let c = have + 1; c <= GRID_MIN_COLS; c++) {
const th = document.createElement("th");
th.textContent = columnName(c);
th.style.cursor = "pointer";
th.title = t("xlsx.select_column");
headRow.appendChild(th);
}
}
const cols = headRow.querySelectorAll("th:not(.xlsx-corner)").length;
// Pad the rows the server already rendered: a sparse/empty sheet arrives
// with fewer columns (ex. 8 pour une création) alors que l'en-tête vient
// d'être complété jusqu'à Z — sans ce comblement, les lignes 1..N
// n'affichent aucune cellule de I à Z.
tbody.querySelectorAll("tr").forEach((tr) => {
const existing = tr.querySelectorAll("td[data-cell]").length;
if (existing >= cols) return;
let rowNum = 0;
const firstTd = tr.querySelector("td[data-cell]");
if (firstTd) {
const p = parseRef(firstTd.dataset.cell);
if (p) rowNum = p.row;
}
if (!rowNum) {
const thNum = tr.querySelector("th.xlsx-rownum");
rowNum = Number(thNum ? thNum.textContent.trim() : NaN) || 0;
}
if (!(rowNum >= 1)) return;
const fragRow = document.createDocumentFragment();
for (let c = existing + 1; c <= cols; c++) {
const td = document.createElement("td");
td.dataset.cell = `${columnName(c)}${rowNum}`;
td.setAttribute("role", "gridcell");
fragRow.appendChild(td);
}
tr.appendChild(fragRow);
});
// Colgroup: the row-number gutter plus one entry per column, so the
// explicit widths still apply on the padded grid.
let cg = table.querySelector("colgroup.xlsx-cols");
if (cg) cg.replaceChildren();
else {
cg = document.createElement("colgroup");
cg.className = "xlsx-cols";
table.prepend(cg);
}
const gutter = document.createElement("col");
gutter.style.width = "44px";
cg.appendChild(gutter);
for (let c = 1; c <= cols; c++) {
const col = document.createElement("col");
cg.appendChild(col);
}
invalidateCellIndex(panel);
// Wire the fresh cells exactly like the ones the server sent.
if (!readOnly) {
tbody.querySelectorAll("td[data-cell]").forEach((td) => {
if (td.dataset.wired !== "1") setupCell(td);
});
}
applyGridA11y(panel);
};
/**
* #184 — materialise the next block of empty rows (GRID_CHUNK_ROWS at a
* time, up to GRID_MIN_ROWS). The grid may reach A → Z × 1000, but only the
* rows the reader actually reaches are created: materialising all 1000 up
* front means ~26 000 live editable cells per sheet, each one wired, which
* pinned ~0.3 GB per render and exhausted the heap in the JSDOM suite.
* Returns the number of rows added.
*/
const padMoreRows = (panel) => {
const table = panel.querySelector(".xlsx-table");
const tbody = table && table.querySelector("tbody");
const headRow = table && table.querySelector("thead tr");
if (!tbody || !headRow) return 0;
const cols = headRow.querySelectorAll("th:not(.xlsx-corner)").length;
const rows = tbody.querySelectorAll("tr").length;
if (!cols || rows >= GRID_MIN_ROWS) return 0;
const stop = Math.min(rows + GRID_CHUNK_ROWS, GRID_MIN_ROWS);
const frag = document.createDocumentFragment();
const added = [];
for (let r = rows + 1; r <= stop; r++) {
const tr = document.createElement("tr");
tr.setAttribute("role", "row");
const th = document.createElement("th");
th.className = "xlsx-rownum";
th.setAttribute("role", "rowheader");
th.textContent = String(r);
th.style.cursor = "pointer";
th.title = t("xlsx.select_row");
tr.appendChild(th);
for (let c = 1; c <= cols; c++) {
const td = document.createElement("td");
td.dataset.cell = `${columnName(c)}${r}`;
td.setAttribute("role", "gridcell");
tr.appendChild(td);
}
frag.appendChild(tr);
added.push(tr);
}
tbody.appendChild(frag);
invalidateCellIndex(panel);
if (!readOnly) {
added.forEach((tr) => tr.querySelectorAll("td[data-cell]").forEach(setupCell));
}
applyGridA11y(panel);
return stop - rows;
};
const panels = isCsv
? `
`
: "";
// #154-A1 — status pills and the grouped command bar are built by
// buildCommandBar() (frontend/js/xlsx/command-bar.js).
area.innerHTML = `
${menuBarHtml}
${actionsHtml}
${tabs}
${fileHtml}
${statusBar}
fx
${lossWarning}
${panels}
`;
// A re-render invalidates any open context menu (it targets the old DOM).
closeContextMenu();
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
// Fetched lazily on first open of the dashboard tab; a workbook without
// anything notable hides the button entirely (no empty panel).
// #185 — `dashBtn` is declared out here so Affichage ▸ Afficher ▸ Tableau de
// bord (and Données ▸ Plages nommées) can reach the very same panel.
let dashBtn = null;
if (!isCsv && !readOnly) {
dashBtn = el("button", {
class: "btn-action xlsx-dash-btn",
id: "xlsx-dashboard-btn",
type: "button",
title: t("xlsx.dashboard_btn"),
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
(area.querySelector("#xlsx-view-group") || area.querySelector(".xlsx-toolbar-actions")).appendChild(dashBtn);
const inspector = area.querySelector("#xlsx-inspector");
const inspectorBody = area.querySelector("#xlsx-inspector-body");
const closeInspector = () => {
const panel = inspectorBody.querySelector(".xlsx-dashboard");
if (panel) panel.remove();
inspector.classList.remove("open");
inspector.setAttribute("aria-hidden", "true");
dashBtn.classList.remove("active");
};
dashBtn.addEventListener("click", async () => {
// #154-A3 — the dashboard lives in a right-hand inspector so the grid
// stays visible beside it (it used to be prepended into the grid flow).
if (inspectorBody.querySelector(".xlsx-dashboard")) { closeInspector(); return; }
dashBtn.classList.add("active");
inspector.classList.add("open");
inspector.setAttribute("aria-hidden", "false");
const panel = document.createElement("div");
panel.className = "xlsx-dashboard";
panel.innerHTML = renderDashboardLoading();
inspectorBody.prepend(panel);
try {
const dash = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`,
);
panel.innerHTML = renderDashboardHtml(dash);
safeCreateIcons();
// #154-A5 — clicking a named range reveals its first cell in the grid.
panel.querySelectorAll(".xlsx-range-row[data-ref]").forEach((row) => {
row.addEventListener("click", () => revealRange(row.dataset.ref));
});
} catch (err) {
panel.innerHTML = `
`;
}
});
area.querySelector("#xlsx-inspector-close").addEventListener("click", closeInspector);
// #154-A3 — the assistant is the existing global side panel; the inspector
// just gives it a contextual entry point from the spreadsheet.
area.querySelector("#xlsx-inspector-ai").addEventListener("click", () => { openAssistant(); });
// #154-A5 — the inspector is resizable (width restored per session).
const resizeHandle = area.querySelector("#xlsx-inspector-resize");
const WIDTH_KEY = "obsigate_xlsx_inspector_width";
const clampWidth = (w) => Math.min(640, Math.max(260, w));
const savedWidth = Number(localStorage.getItem(WIDTH_KEY));
if (savedWidth >= 260 && savedWidth <= 640) {
inspector.style.flexBasis = `${savedWidth}px`;
inspector.style.width = `${savedWidth}px`;
}
let resizing = false;
const onMove = (e) => {
if (!resizing) return;
const width = clampWidth(inspector.getBoundingClientRect().right - e.clientX);
inspector.style.flexBasis = `${width}px`;
inspector.style.width = `${width}px`;
};
const onUp = () => {
if (!resizing) return;
resizing = false;
document.body.style.userSelect = "";
localStorage.setItem(WIDTH_KEY, String(Math.round(inspector.getBoundingClientRect().width)));
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault();
resizing = true;
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => {
const dirty = dirtyCount() > 0;
saveBtn.disabled = !dirty;
saveBtn.classList.toggle("is-dirty", dirty);
// #154-A2 — star a sheet tab that holds unsaved edits.
panelEls.forEach((panel) => {
const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`);
if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0);
});
};
// #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept
// and the user can retry the save without losing their work.
const bannerHost = document.createElement("div");
bannerHost.className = "xlsx-banner-host";
area.querySelector(".xlsx-panels").before(bannerHost);
const showConflict = (msg) => {
bannerHost.innerHTML = "";
const banner = document.createElement("div");
banner.className = "xlsx-banner xlsx-banner-conflict";
banner.setAttribute("role", "alert");
banner.innerHTML = `
${escapeHtml(msg)}
`;
// #156-A12 — the file changed elsewhere: re-read it first so the retry
// carries the CURRENT revision instead of failing on the same stale token.
banner.querySelector(".xlsx-banner-retry").addEventListener("click", async () => {
bannerHost.innerHTML = "";
try {
const fresh = await api(
`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
);
if (fresh && fresh.xlsx_revision) revision = fresh.xlsx_revision;
} catch (_) { /* keep the previous token: the retry is refused again */ }
saveBtn.click();
});
banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => {
bannerHost.innerHTML = "";
});
bannerHost.appendChild(banner);
safeCreateIcons();
};
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
// fetched window by window from GET …/xlsx/sheet when the user reaches the
// end of a truncated sheet (scroll sentinel) or clicks « Charger la suite ».
// Appended rows reuse the exact same edit pipeline as the initial render.
const wireLazyRows = (panel) => {
const meta = sheets[Number(panel.dataset.sheet)] || {};
if (!meta.truncated) return;
const wrapper = panel.querySelector(".csv-table-wrapper");
const table = panel.querySelector(".xlsx-table tbody");
if (!wrapper || !table) return;
let offset = Number(meta.rows) || 0;
const total = Number(meta.total_rows) || 0;
let loading = false;
let done = offset >= total;
const foot = document.createElement("div");
foot.className = "xlsx-load-more";
const refreshFoot = () => {
foot.textContent = done
? ""
: `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`;
foot.classList.toggle("done", done);
};
refreshFoot();
foot.addEventListener("click", () => { if (!done) loadMore(); });
wrapper.insertAdjacentElement("afterend", foot);
const appendWindow = (win) => {
const doc = new DOMParser().parseFromString(`
${win.html}
`, "text/html");
// No `tbody` selector: the fragment embeds its own wrapper div, so the
// parse yields bare `
` inside `
` (the rows we want) — while
// any `tbody` in the fragment belongs to the *embedded* wrapper table.
doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr));
offset = win.offset + win.rows;
done = !win.has_more;
refreshFoot();
// The new rows must behave like the initial ones: contenteditable,
// dirty tracking, cached-value tooltip, icons in the fresh footnote.
panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell);
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
// #153 A15 — the window carries its own style/merge metadata: fold it
// into the sheet meta and re-apply, so lazy rows look like the initial
// render (applySheetMeta is idempotent over already-processed cells).
const sheetMeta = sheets[Number(panel.dataset.sheet)];
if (sheetMeta) {
Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {});
Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {});
Object.assign(sheetMeta.comments || (sheetMeta.comments = {}), win.comments || {});
Object.assign(sheetMeta.formats || (sheetMeta.formats = {}), win.formats || {});
Object.assign(sheetMeta.colwidths || (sheetMeta.colwidths = {}), win.colwidths || {});
Object.assign(sheetMeta.rowheights || (sheetMeta.rowheights = {}), win.rowheights || {});
if (!sheetMeta.merges) sheetMeta.merges = [];
(win.merges || []).forEach((m) => {
if (!sheetMeta.merges.includes(m)) sheetMeta.merges.push(m);
});
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
applySheetMeta(panel);
refreshPanelFormulas(panel);
}
applyGridA11y(panel);
safeCreateIcons();
};
const loadMore = async () => {
if (loading || done) return;
loading = true;
const label = foot.textContent;
foot.textContent = t("xlsx.loading_more");
try {
const win = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/sheet?path=${encodeURIComponent(data.path)}` +
`&sheet=${encodeURIComponent(sheets[Number(panel.dataset.sheet)].name)}` +
`&offset=${offset}&limit=500`,
);
appendWindow(win);
} catch (err) {
foot.textContent = label; // restore: a failed fetch must not eat the button
showToast(`${t("xlsx.load_error")}: ${err.message || err}`, "error");
} finally {
loading = false;
}
};
// #154-A4 — prefer IntersectionObserver (less main-thread work than a
// scroll listener); the scroll listener stays as a fallback for
// environments without IO (JSDOM).
if (typeof IntersectionObserver === "function") {
const io = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadMore();
}, { root: wrapper, rootMargin: "120px" });
io.observe(foot);
} else {
wrapper.addEventListener("scroll", () => {
if (loading || done) return;
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
});
}
};
// ── Formula engine: local evaluation of `=…` cells ───────────────────
// The viewer computes formulas itself (frontend/js/xlsx/formula.js, no
// round-trip): display values update live while the raw formula stays in
// `td.dataset.formula` (what gets saved). Anything the parser rejects
// keeps its original rendering — Excel remains the source of truth.
const formulaLocale = () => {
try { return getLocale() === "fr" ? "fr" : "en"; } catch (_) { return "en"; }
};
/** User-typed text of a cell (helper spans excluded). */
const cleanCellText = (td) => {
if (!td) return "";
const clone = td.cloneNode(true);
clone.querySelectorAll(".xlsx-cached,.xlsx-comment-dot").forEach((el) => el.remove());
return clone.textContent;
};
/** Storable content: the formula when the engine manages the cell. */
const cellRaw = (td) => (td && td.dataset.formula) || cleanCellText(td);
const barTextOf = (td) => cellRaw(td);
const panelSheetName = (panel) => {
const idx = Number(panel && panel.dataset ? panel.dataset.sheet : NaN);
return (Number.isFinite(idx) && sheets[idx] ? sheets[idx].name : "") || "";
};
const panelBySheet = (name) => {
const idx = sheets.findIndex((s) => s && s.name === name);
if (idx < 0) return null;
return panelEls.find((p) => Number(p.dataset.sheet) === idx) || null;
};
const readRawCell = (sheetName, ref) => {
const panel = panelBySheet(sheetName);
if (!panel) return undefined; // unknown sheet → #REF!, not blank
const td = panel.querySelector(`td[data-cell="${String(ref).toUpperCase()}"]`);
if (!td) return null;
return cellRaw(td);
};
const decorateFormulaCell = (td, value, locale) => {
td.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
if (value && value.t === "link") {
const a = document.createElement("a");
a.href = value.url;
a.className = "xlsx-formula-link";
a.rel = "noopener";
a.textContent = value.label;
a.title = value.url;
td.textContent = "";
td.appendChild(a);
} else {
td.textContent = displayValue(value, locale);
}
td.dataset.display = td.textContent;
};
/**
* Adopt `raw` as the cell's content: manage formulas, release the rest.
* With `render=false` (keystrokes in flight) only the identity is adopted —
* dependents read the latest valid formula while the caret is untouched.
*/
const adoptCellRaw = (td, raw, scope, render = true) => {
const locale = scope.locale;
if (!isFormulaText(raw)) {
delete td.dataset.formula;
delete td.dataset.display;
return false;
}
let ast;
try {
ast = parseFormula(raw);
} catch (_) {
// Half-typed while editing: keep the previous identity so dependents
// stay stable; once committed, release so the text saves as typed.
if (render) {
delete td.dataset.formula;
delete td.dataset.display;
}
return false;
}
let value;
try {
value = evaluate(ast, scope);
} catch (_) {
if (render) {
delete td.dataset.formula;
delete td.dataset.display;
}
return false;
}
// Unknown function but Excel left a cached result: defer to it instead
// of showing #NOM? — the shadow stays the display (A12 fallback).
if (value && value.t === "e" && value.code === "NAME"
&& td.querySelector(".xlsx-cached[data-cached-value]")) {
delete td.dataset.formula;
delete td.dataset.display;
return false;
}
td.dataset.formula = raw.trim();
if (render) {
decorateFormulaCell(td, value, locale);
decorateComments(td);
}
return true;
};
const refreshPanelFormulas = (panel, opts = {}) => {
if (!panel || !panel.isConnected) return;
// Frappe en cours (`live`) : sans cellule gérée par le moteur, aucun
// affichage ne peut changer — les dépendants n'existent que via
// `dataset.formula`. On évite alors le balayage des ~26 000 cellules à
// chaque touche (le commit au `blur` reste, lui, un balayage complet qui
// adopte les nouvelles formules). Test natif, arrêté au premier succès.
// (`opts` peut recevoir l'index d'un `forEach` : accès défensif.)
if (opts && opts.live && !panel.querySelector("td[data-formula]")) return;
const locale = formulaLocale();
const sheet = panelSheetName(panel);
const scope = createScope({ readRaw: readRawCell, sheet, locale });
panel.querySelectorAll("td[data-cell]").forEach((td) => {
// #184 — a padded grid is mostly empty: an empty cell without a managed
// formula can never produce a value, so skip it BEFORE the expensive
// cellRaw()/cloneNode() path. Without this, one keystroke would clone
// 26 000 nodes.
if (!td.dataset.formula && !td.textContent) return;
// While the user is typing in the cell, adopt the identity (so
// dependents follow) but never rewrite its text under the caret.
// Programmatic writes carry editing=1 without focus: those render fully.
const typing = td === document.activeElement && td.dataset.editing === "1";
if (typing) {
const raw = cleanCellText(td);
if (isFormulaText(raw) || td.dataset.formula) adoptCellRaw(td, raw, scope, false);
return;
}
const managed = Boolean(td.dataset.formula);
if (managed && td.textContent === (td.dataset.display ?? "")) {
adoptCellRaw(td, td.dataset.formula, scope); // deps may have changed
} else if (managed) {
// The user overwrote the display: adopt the CURRENT text, never the
// stale dataset (cellRaw would hand back the old formula).
adoptCellRaw(td, cleanCellText(td), scope);
} else {
const raw = cellRaw(td);
if (isFormulaText(raw)) adoptCellRaw(td, raw, scope);
}
});
};
const refreshAllFormulas = () => {
panelEls.forEach(refreshPanelFormulas);
};
/** Reflect the active cell's toggles (bold/italic/strike/underline, font size). */
const updateSheetsToolbarStates = () => {
const td = activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null;
const setToggle = (id, on) => {
const b = area.querySelector(`#${id}`);
if (!b) return;
b.classList.toggle("active", Boolean(on));
b.setAttribute("aria-pressed", String(Boolean(on)));
};
setToggle("xs-b", td && (td.style.fontWeight === "600" || td.style.fontWeight === "bold" || td.style.fontWeight === "700"));
setToggle("xs-i", td && td.style.fontStyle === "italic");
setToggle("xs-u", td && (td.style.textDecoration || "").includes("underline"));
setToggle("xs-s", td && (td.style.textDecoration || "").includes("line-through"));
const sizeEl = area.querySelector("#xs-fssize");
if (sizeEl) {
const px = td && td.style.fontSize ? Number.parseFloat(td.style.fontSize) : NaN;
const size = String(Number.isFinite(px) ? Math.round(px) : 11);
if (![...sizeEl.options].some((o) => o.value === size)) {
const opt = document.createElement("option");
opt.value = size;
opt.textContent = size;
sizeEl.appendChild(opt);
}
sizeEl.value = size;
}
const wrapBtn = area.querySelector("#xs-wrap");
if (wrapBtn) {
const on = td && td.style.whiteSpace === "normal";
wrapBtn.classList.toggle("active", Boolean(on));
wrapBtn.setAttribute("aria-pressed", String(Boolean(on)));
}
};
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
// One active cell per viewer: clicking or arrowing into a cell shows its
// A1 name in the bar; the input mirrors the cell text and typing there
// edits the cell live (Enter commits, Escape reverts, then re-focuses).
let activeTd = null;
const activeCellEl = area.querySelector("#xlsx-active-cell");
const formulaInput = area.querySelector("#xlsx-formula-input");
const syncing = { value: false }; // guard against input-event feedback loops
// #155 — Excel-like range selection: { panel, anchor, focus } in (row, col).
let selection = null;
let suppressSelectionReset = false;
const cellName = (td) => td?.dataset.cell || "";
// #181-A2 / #182 — the selected block is outlined on its four real edges.
const SELECT_EDGE_CLASSES = [
"xlsx-sel-top", "xlsx-sel-bottom", "xlsx-sel-left", "xlsx-sel-right",
];
// #182 — class selectors need their leading dot: a bare `xlsx-sel-top` is a
// TYPE selector (an `` element), so the query matched nothing
// and a previous outline was never cleared — the boxes piled up on every
// focus change. (Le balayage global est désormais remplacé par `clearMarks`
// ci-dessous : plus aucun `querySelectorAll` sur toute la grille.)
// Marques de sélection incrémentales : la grille compte ~26 000 cellules, et
// chaque changement de sélection (focus, flèche, survol pendant un glisser)
// balayait tout le panneau en `querySelectorAll` pour effacer les anciennes
// marques — d'où les saccades, et les indicateurs fantômes quand le balayage
// ratait sa cible (ex. `remove(".xlsx-hdr-selected")` avec un point : le
// sélecteur ne correspondait jamais). Seuls les nœuds précédemment marqués
// sont désormais retouchés : O(sélection) au lieu de O(grille).
let lastMarks = { cells: [], edges: [], headers: [] };
// Racine visuelle du viewer : l'état « sélection multiple » est porté par
// `.xlsx-viewer` (et non par `area` = #content-area, qui est le parent) —
// le sélecteur CSS `.xlsx-viewer.xlsx-range-multi` exige les deux classes
// sur le même élément. Cache résolu paresseusement : applySelection() tourne
// à chaque mousemove d'un glisser.
let viewerRootEl = null;
const viewerRoot = () => {
if (!viewerRootEl || !viewerRootEl.isConnected) {
viewerRootEl = area.querySelector(".xlsx-viewer") || area;
}
return viewerRootEl;
};
const clearMarks = () => {
for (const td of lastMarks.cells) {
if (td && td.classList) td.classList.remove("xlsx-selected");
}
for (const td of lastMarks.edges) {
if (td && td.classList) td.classList.remove(...SELECT_EDGE_CLASSES);
}
for (const el of lastMarks.headers) {
if (el && el.classList) el.classList.remove("xlsx-hdr-selected");
}
// Plus de sélection → plus de teinte multi-cellules.
viewerRoot().classList.remove("xlsx-range-multi");
lastMarks = { cells: [], edges: [], headers: [] };
};
const clearSelectionClasses = () => {
clearMarks();
};
const selectionBounds = () => {
if (!selection) return null;
const { anchor, focus } = selection;
return {
r1: Math.min(anchor.row, focus.row),
r2: Math.max(anchor.row, focus.row),
c1: Math.min(anchor.col, focus.col),
c2: Math.max(anchor.col, focus.col),
};
};
const rangeLabel = () => {
const b = selectionBounds();
if (!b) return "";
const first = `${columnName(b.c1)}${b.r1}`;
return b.r1 === b.r2 && b.c1 === b.c2 ? first : `${first}:${columnName(b.c2)}${b.r2}`;
};
// Paint the current selection and show its address in the Name Box.
// #181-A2 — the selected cells carry `xlsx-selected` (a tint) plus the
// `xlsx-sel-*` edge classes, which trace the block exactly the way
// Excel/Sheets draw it, instead of a flat wash over every cell.
const paintSelectionEdges = (panel, b) => {
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
if (!td) continue;
let touched = false;
if (r === b.r1) { td.classList.add("xlsx-sel-top"); touched = true; }
if (r === b.r2) { td.classList.add("xlsx-sel-bottom"); touched = true; }
if (c === b.c1) { td.classList.add("xlsx-sel-left"); touched = true; }
if (c === b.c2) { td.classList.add("xlsx-sel-right"); touched = true; }
if (touched) lastMarks.edges.push(td);
}
}
};
const applySelection = () => {
if (!selection) return;
const { panel } = selection;
const b = selectionBounds();
clearMarks();
// Google Sheets : la teinte de fond n'apparaît qu'à partir de DEUX
// cellules (`.xlsx-range-multi` sur le viewer). Une sélection d'une seule
// cellule ne garde que son cadre accent (xlsx-sel-* + xlsx-active), sans
// changer le fond de la cellule.
viewerRoot().classList.toggle("xlsx-range-multi", b.r1 !== b.r2 || b.c1 !== b.c2);
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
if (td) {
td.classList.add("xlsx-selected");
lastMarks.cells.push(td);
}
}
}
// #181-A2 — the range outline, plus the header highlight so the selected
// row numbers / column letters read as selected too.
paintSelectionEdges(panel, b);
paintSelectedHeaders(panel, b);
activeCellEl.value = rangeLabel();
updateMinibar();
};
// Highlight the headers covered by the current range (`A`, `B`… / `1`, `2`…).
const paintSelectedHeaders = (panel, b) => {
const head = panel.querySelector(".xlsx-table thead tr");
if (head) {
[...head.querySelectorAll("th:not(.xlsx-corner)")].forEach((th, i) => {
if (i + 1 >= b.c1 && i + 1 <= b.c2) {
th.classList.add("xlsx-hdr-selected");
lastMarks.headers.push(th);
}
});
}
panel.querySelectorAll("th.xlsx-rownum").forEach((th) => {
const row = Number(th.textContent);
if (row >= b.r1 && row <= b.r2) {
th.classList.add("xlsx-hdr-selected");
lastMarks.headers.push(th);
}
});
};
const setSelection = (anchorTd, focusTd = anchorTd) => {
if (!anchorTd) { clearSelectionClasses(); selection = null; return; }
const panel = anchorTd.closest(".xlsx-panel");
const anchor = parseRef(cellName(anchorTd));
const focus = parseRef(cellName(focusTd));
if (!panel || !anchor || !focus) return;
selection = { panel, anchor, focus };
applySelection();
};
const inSelection = (td) => {
const b = selectionBounds();
const p = parseRef(cellName(td));
if (!b || !p || !selection || selection.panel !== td.closest(".xlsx-panel")) return false;
return p.row >= b.r1 && p.row <= b.r2 && p.col >= b.c1 && p.col <= b.c2;
};
// La cellule portant le contour : suivie par référence plutôt que par un
// `querySelector` sur toute la grille à chaque déplacement. `markedActiveTd`
// (le contour) est distinct d'`activeTd` (le focus logique) : les deux ne
// divergent que sur les chemins d'extension de plage, et le contour reste
// nettoyé dans tous les cas, sans balayage.
let markedActiveTd = null;
const unmarkActive = () => {
if (markedActiveTd && markedActiveTd.classList) markedActiveTd.classList.remove("xlsx-active");
markedActiveTd = null;
};
const setActiveCell = (td) => {
unmarkActive();
activeTd = td || null;
if (!td) {
activeCellEl.value = "";
formulaInput.value = "";
formulaInput.disabled = true;
setSelection(null);
return;
}
td.classList.add("xlsx-active");
markedActiveTd = td;
activeCellEl.value = cellName(td);
formulaInput.disabled = false;
formulaInput.value = barTextOf(td);
setSelection(td);
updateSheetsToolbarStates();
};
const syncFormulaBar = (td) => {
if (td !== activeTd) return;
if (!syncing.value) formulaInput.value = barTextOf(td);
};
// #154-A5 — reveal a named range's first cell in the grid (dashboard → grid).
const revealRange = (ref) => {
const cellRef = firstCellOfRange(ref);
if (!cellRef) return;
const sheetName = sheetOfRef(ref);
let panel = visiblePanel();
if (sheetName) {
const idx = sheets.findIndex((s) => s.name === sheetName);
const tab = idx >= 0 ? area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`) : null;
if (tab) { tab.click(); panel = panelEls[idx]; }
}
const td = panel.querySelector(`td[data-cell="${cellRef}"]`);
if (!td) { showToast(t("xlsx.range_not_visible"), "info"); return; }
setActiveCell(td);
td.focus();
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "center", inline: "center" });
};
// #154-A5 — parseRef / columnName / findTd live in frontend/js/xlsx/refs.js.
// #181-A1/A2 — navigation from a SELECTED cell. Shift+arrow extends the range
// from the anchor (#181-A2); a plain arrow collapses to a single cell. In both
// cases the cell stays selected, never editing.
const moveActive = (td, key, forward = true, extend = false) => {
const ref = parseRef(cellName(td));
if (!ref) return;
let { row, col } = ref;
if (key === "Tab") col += forward ? 1 : -1;
else if (key === "ArrowRight") col += 1;
else if (key === "ArrowLeft") col -= 1;
else if (key === "ArrowDown") row += 1;
else if (key === "ArrowUp") row -= 1;
if (row < 1 || col < 1) return;
const panel = td.closest(".xlsx-panel");
const next = findTd(panel, row, col);
if (!next) return; // edge of the rendered window: no wrap
// Leaving edit mode: a move always commits whatever was being typed.
if (isTypingInCell(td)) {
commitEdit();
exitEditing(td);
}
// #155/#181-A2 — Shift+arrow extends the selection from the current anchor.
if (extend && selection && selection.panel === panel) {
suppressSelectionReset = true;
next.focus();
suppressSelectionReset = false;
selection.focus = { row, col };
applySelection();
activeTd = next;
formulaInput.value = barTextOf(next);
return;
}
next.focus();
setActiveCell(next);
};
formulaInput.addEventListener("input", () => {
if (!activeTd) return;
syncing.value = true;
activeTd.textContent = formulaInput.value;
syncing.value = false;
activeTd.classList.add("xlsx-dirty");
refreshSaveState();
refreshPanelFormulas(activeTd.closest(".xlsx-panel"));
});
formulaInput.addEventListener("keydown", (e) => {
if (!activeTd) return;
if (e.key === "Enter") {
e.preventDefault();
activeTd.blur();
activeTd.focus();
} else if (e.key === "Escape") {
e.preventDefault();
activeTd.textContent = activeTd.dataset.orig;
activeTd.classList.remove("xlsx-dirty");
syncFormulaBar(activeTd);
refreshSaveState();
refreshPanelFormulas(activeTd.closest(".xlsx-panel"));
activeTd.focus();
}
});
// ── #156-A10 — undo/redo unifié ──────────────────────────
// One stack for everything the viewer can reverse: a cell edit (typed,
// pasted, cleared), a row sort, and the REVERSIBLE structure actions
// (insert, rename, duplicate, add sheet — a delete is not replayable, the
// `.bak` stays the safety net).
let pendingEdit = null;
const undoBtn = area.querySelector("#xlsx-undo-btn");
const redoBtn = area.querySelector("#xlsx-redo-btn");
const fileHistory = xlsxHistory(historyKey);
const undoStack = fileHistory.undo;
const redoStack = fileHistory.redo;
const updateUndoButtons = () => {
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
};
const pushHistory = (entry) => {
undoStack.push(entry);
redoStack.length = 0;
updateUndoButtons();
};
/** Sheet identity of a panel: its name when known (a CSV has none). */
const panelKey = (panel) => {
const idx = Number(panel?.dataset.sheet);
return sheets[idx]?.name || String(Number.isFinite(idx) ? idx : 0);
};
/** Reverse lookup: a panel by sheet name, then by rendered index. */
const panelForKey = (key) => {
const byName = sheets.findIndex((s) => s.name === key);
if (byName >= 0) return panelEls[byName];
return panelEls.find((p) => p.dataset.sheet === String(key)) || null;
};
const rowNumOf = (tr) => Number(tr.querySelector("th.xlsx-rownum")?.textContent);
const rowNums = (list) => list.map(rowNumOf);
// One history entry per changed cell: shared by the focus/blur edit cycle
// and by the range operations (paste, cut, clear) so they can all be undone.
const recordEdit = (td, before, after, beforeFormula = null, afterFormula = null) => {
if (before === after && (beforeFormula ?? null) === (afterFormula ?? null)) return;
pushHistory({
kind: "cell", sheet: panelKey(td.closest(".xlsx-panel")), ref: td.dataset.cell,
before, after, beforeFormula: beforeFormula ?? null, afterFormula: afterFormula ?? null,
});
};
const commitEdit = () => {
if (!pendingEdit) return;
const { td, before, beforeFormula } = pendingEdit;
pendingEdit = null;
recordEdit(td, before, td.textContent, beforeFormula ?? null, td.dataset.formula ?? null);
};
const beginEdit = (td) => {
if (pendingEdit && pendingEdit.td !== td) commitEdit();
if (!pendingEdit) pendingEdit = { td, before: td.textContent, beforeFormula: td.dataset.formula ?? null };
};
const applyCellHistory = (entry, value) => {
const panel = panelForKey(entry.sheet) || panelEls[0];
const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`);
if (!td) return;
td.textContent = value;
const formulaKey = value === entry.after ? "afterFormula" : "beforeFormula";
if (entry[formulaKey] !== undefined) {
if (entry[formulaKey] == null) delete td.dataset.formula;
else td.dataset.formula = entry[formulaKey];
delete td.dataset.display;
}
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
if (panel) refreshPanelFormulas(panel);
if (td === activeTd) syncFormulaBar(td);
refreshSaveState();
};
/** Replay a recorded row order (undo/redo of a sort). */
const applyRowOrder = (entry, order) => {
const tbody = panelForKey(entry.sheet)?.querySelector(".xlsx-table tbody");
if (!tbody || !order) return;
const byNum = new Map([...tbody.querySelectorAll("tr")].map((tr) => [rowNumOf(tr), tr]));
if (order.length !== byNum.size || order.some((n) => !byNum.has(n))) return;
order.forEach((n) => tbody.appendChild(byNum.get(n)));
};
/** Replay a recorded filter visibility (and its needle). */
const applyRowVisibility = (entry, side) => {
const panel = panelForKey(entry.sheet);
const hidden = new Set(entry[side]);
if (panel) {
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
tr.style.display = hidden.has(rowNumOf(tr)) ? "none" : "";
});
}
findInput.value = entry[`${side}Needle`] || "";
};
/** Replay a structure batch through the same endpoint as the UI. */
const applyStructureHistory = async (entry, side) => {
const actions = entry[side];
if (!actions?.length) return;
try {
await putStructure(actions, false, { quiet: true, record: false });
} catch (err) {
await structureError(err, actions);
}
};
const applyEntry = (entry, side) => {
if (entry.kind === "cell") return applyCellHistory(entry, entry[side]);
if (entry.kind === "order") return applyRowOrder(entry, entry[side]);
if (entry.kind === "hidden") return applyRowVisibility(entry, side);
if (entry.kind === "structure") return applyStructureHistory(entry, side);
return undefined;
};
const undo = () => {
const entry = undoStack.pop();
if (!entry) return;
applyEntry(entry, "before");
redoStack.push(entry);
updateUndoButtons();
};
const redo = () => {
const entry = redoStack.pop();
if (!entry) return;
applyEntry(entry, "after");
undoStack.push(entry);
updateUndoButtons();
};
if (undoBtn) undoBtn.addEventListener("click", undo);
if (redoBtn) redoBtn.addEventListener("click", redo);
// The command bar always renders the buttons disabled: re-enable them when
// the history survived a re-render.
updateUndoButtons();
// #156-A6 — undo/redo, save, clipboard and grid navigation all live in the
// grid-level keydown handler registered below (one place, one priority
// order); the cell handler only keeps the in-cell keys.
// #181-A1 — set by enterEditing() right before it focuses the cell, so the
// cell's own `focus` handler knows the focus is part of entering edit mode
// (and does not reset the state we just installed).
let editingRequested = false;
const setupCell = (td) => {
td.contentEditable = "true";
td.spellcheck = false;
// Focusable without a pointing device: JSDOM requires it to fire focus
// events on contenteditable cells, and a keyboard user tabbing into the
// table from outside lands on the first cell thanks to it.
td.tabIndex = 0;
td.dataset.orig = td.textContent;
// #184 — explicit wiring marker. `dataset.orig` cannot serve: it is "" for
// an empty cell, so the padding pass would wire the server cells a second
// time and double every listener.
td.dataset.wired = "1";
// #181-A1 — every cell starts in SELECTION mode: no text caret, just the
// outline. Editing is a second, explicit state (double-click, F2, first
// printable keystroke) that reveals the caret and accepts typing.
td.dataset.editing = "";
td.classList.add("xlsx-cell");
td.addEventListener("input", () => {
// #156-A6 — typing puts the cell in “edit” mode: Delete then deletes a
// character instead of clearing the whole selection (spreadsheet rule).
enterEditing(td);
td.classList.add("xlsx-dirty");
syncFormulaBar(td);
refreshSaveState();
// `live` : la frappe reste fluide même sur une grande grille (voir
// refreshPanelFormulas — le commit au blur recalcule tout).
refreshPanelFormulas(td.closest(".xlsx-panel"), { live: true });
});
// #181-A1 — double-click is the pointer route into editing: the caret must
// appear where the user pointed (native caret placement, we only lift the
// caret colour).
td.addEventListener("dblclick", (e) => {
e.preventDefault();
enterEditing(td);
placeCaretAtPoint(td, e.clientX, e.clientY);
});
td.addEventListener("focus", () => {
// Focusing alone never starts editing: the cell is only SELECTED — unless
// enterEditing() is what asked for the focus (double-click, F2, first
// keystroke), otherwise this handler would undo the edit it just started.
if (!editingRequested) td.dataset.editing = "";
editingRequested = false;
beginEdit(td);
// #155 — a range extension keeps the selection; only the bar follows.
if (suppressSelectionReset) {
activeTd = td;
activeCellEl.value = cellName(td);
formulaInput.disabled = false;
formulaInput.value = barTextOf(td);
return;
}
setActiveCell(td);
});
td.addEventListener("blur", () => {
commitEdit();
exitEditing(td);
refreshPanelFormulas(td.closest(".xlsx-panel"));
});
td.addEventListener("keydown", (e) => {
const mod = e.ctrlKey || e.metaKey;
// #156-A6 — keys moved to the grid handler when combined with Ctrl
// (Ctrl+flèches = jump to the edge of a data run, Ctrl+Home/End…).
const move = e.key === "Tab" || e.key.startsWith("Arrow");
if (move && mod) return;
if (e.key === "Enter" && e.shiftKey) {
// #156-A6 — Maj+Entrée: line break inside the cell (plain text only).
e.preventDefault();
enterEditing(td);
insertTextAtCaret(td, "\n");
return;
}
if (e.key === "F2") { e.preventDefault(); startEditing(td); return; }
if (e.key === "Delete" && !mod && !isTypingInCell(td)) {
// #156-A6 — Suppr clears the selection when the cell is not edited.
e.preventDefault();
const ref = parseRef(cellName(td));
if (!ref) return;
const panel = td.closest(".xlsx-panel");
const b = selection && selection.panel === panel
? selectionBounds()
: { r1: ref.row, r2: ref.row, c1: ref.col, c2: ref.col };
clearRange(panel, b);
return;
}
// #181-A1 — the FIRST printable keystroke switches the selected cell to
// editing and REPLACES its content, exactly like Google Sheets / Excel
// (typing never appends to an existing value). `keydown` runs before the
// browser inserts, so the old content is dropped first; the incoming
// character is then inserted normally by the contenteditable.
if (e.key.length === 1 && !mod && !isTypingInCell(td)) {
enterEditing(td, { replace: true });
beginEdit(td);
return;
}
if (e.key === "Enter" && !e.shiftKey) {
// #181-A3 — Enter is contextual, like Sheets/Excel:
// · while EDITING → commit and move to the cell BELOW;
// · while SELECTED → switch the current cell to editing mode.
e.preventDefault();
if (isTypingInCell(td)) {
const panel = td.closest(".xlsx-panel");
commitEdit();
exitEditing(td);
const ref = parseRef(cellName(td));
const below = ref ? findTd(panel, ref.row + 1, ref.col) : null;
if (below) goTo(below);
else {
td.blur();
td.focus();
}
} else {
startEditing(td);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
// #181-A1 — Escape leaves editing and restores the value.
td.textContent = td.dataset.orig;
td.classList.remove("xlsx-dirty");
exitEditing(td);
syncFormulaBar(td);
refreshSaveState();
refreshPanelFormulas(td.closest(".xlsx-panel"));
return;
}
if (move) {
// #181-A1 — arrows/Tab only MOVE while the cell is selected; while
// editing they belong to the caret (except Tab, handled below).
if (isTypingInCell(td) && !e.key.startsWith("Arrow")) {
// Tab while editing: commit then advance, like Sheets.
e.preventDefault();
const panel = td.closest(".xlsx-panel");
commitEdit();
exitEditing(td);
const forward = !e.shiftKey;
const next = findTd(panel, parseRef(cellName(td))?.row, parseRef(cellName(td))?.col + (forward ? 1 : -1));
if (next) goTo(next);
return;
}
if (isTypingInCell(td) && e.key.startsWith("Arrow")) {
if (e.altKey) {
// #181 — Alt+flèche inserts a line break inside the cell (Sheets).
e.preventDefault();
insertTextAtCaret(td, "\n");
return;
}
// #182 — in Google Sheets / Excel an arrow key during editing
// COMMITS the cell and moves the selection (the caret is not walked
// through the text; Alt+flèche covers the line break). Leaving the
// caret here made the focused cell look frozen: the outline stayed
// on the cell being edited instead of following the navigation.
e.preventDefault();
moveActive(td, e.key, e.key === "ArrowRight" || e.key === "ArrowDown", false);
return;
}
// Excel-like: arrows and Tab move to the neighbour cell. Direction is
// the key's own (Left/Up = back, Right/Down = forward); Tab follows
// Shift. Prevented so Tab never leaves the table and arrows never
// move the caret (they move the SELECTION instead).
e.preventDefault();
const forward = e.key === "Tab"
? !e.shiftKey
: (e.key === "ArrowRight" || e.key === "ArrowDown");
moveActive(td, e.key, forward, e.shiftKey && e.key.startsWith("Arrow"));
}
});
td.addEventListener("paste", (e) => {
// #156-A5 — a multi-cell block fills a range starting at this cell; a
// single value replaces the cell (or is inserted at the caret when the
// user has a live text selection). Plain text only, never HTML (R4).
const payload = clipTextOf(e) || (rangeClipboard ? toTsv(rangeClipboard.rows) : "");
if (!payload) return;
e.preventDefault();
applyPaste(payload, td);
});
};
// #153 A12 — the backend marks the last value Excel computed; the wording is
// translated here so the tooltip follows the UI language.
area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => {
el.title = t("xlsx.cached_value_title");
});
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
// Read-only formats (.xls/.ods) skip the wiring entirely — the toolbar
// save button is disabled and the structure menu does not exist.
if (readOnly) {
saveBtn.disabled = true;
} else {
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
}
panelEls.forEach(wireLazyRows);
panelEls.forEach(applySheetMeta);
refreshAllFormulas();
panelEls.forEach(applyGridA11y);
// The formula bar starts disabled: nothing is selected yet.
setActiveCell(null);
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const idx = tab.dataset.span || tab.dataset.sheet;
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x === tab));
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
// #184 — the sheet coming on screen gets its A → Z × 1000 grid.
ensureGridPadded(visiblePanel());
refreshPanelFormulas(visiblePanel());
updateSheetsToolbarStates();
updateMinibar();
requestStick();
});
});
// =LIEN() cells render a real anchor: plain click selects the cell (the
// grid stays editable), Ctrl/Cmd+click follows the link like a spreadsheet.
const onFormulaLinkClick = (e) => {
const a = e.target && e.target.closest ? e.target.closest("a.xlsx-formula-link") : null;
if (!a) return;
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
window.open(a.href, "_blank", "noopener");
} else {
const td = a.closest("td[data-cell]");
if (td) td.focus();
}
};
area.addEventListener("click", onFormulaLinkClick);
// Replaced on the next render — the teardown at the top of this function
// removes it so the grid it closes over can be collected.
area._xlsxDropFormulaLinkClick = () =>
area.removeEventListener("click", onFormulaLinkClick);
// Formula toggle (#153 A4) — opt-in for this viewing session only.
// #154-A1 — absent on a read-only workbook (nothing can be saved).
const formulaBtn = area.querySelector("#xlsx-formula-btn");
if (formulaBtn) formulaBtn.addEventListener("click", () => {
allowFormula = !allowFormula;
formulaBtn.setAttribute("aria-pressed", String(allowFormula));
formulaBtn.classList.toggle("active", allowFormula);
});
/** Remember the revision a write returned (chained writes stay in sync). */
const keepRevision = (res) => {
if (res && typeof res.revision === "string" && res.revision) revision = res.revision;
return res;
};
const putSheet = isCsv
? async (job, force) => keepRevision(await api(
`/api/file/${encodeURIComponent(data.vault)}/csv/save?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cells: job.cells, if_match: revision }),
},
))
: async (job, force) => keepRevision(await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...job, allow_formula: allowFormula, force, if_match: revision }),
},
));
saveBtn.addEventListener("click", async () => {
// Commit whatever is being typed (cell or formula bar) so the collector
// below reads reconciled content: a focused formula cell still carries
// its previous dataset.formula while its text already changed.
if (document.activeElement && typeof document.activeElement.blur === "function") {
document.activeElement.blur();
}
// An editing flag without a blur (programmatic write, missed event)
// would freeze the cell out of the recompute below: drop it, the blur
// above already committed genuine edits.
area.querySelectorAll('td[data-cell][data-editing="1"]').forEach((td) => {
delete td.dataset.editing;
});
panelEls.forEach(refreshPanelFormulas);
// One PUT per sheet (dirty cells can span tabs before a save).
// BUG-096 — a CSV is served as a single `html` table, with no
// `xlsx_sheets`: reading the sheet name from `sheets[…]` threw a
// TypeError before any request went out, so a CSV edit could never be
// saved. Fall back to the file name (the CSV endpoint ignores it).
const sheetNameOf = (panel) => {
const idx = Number(panel.dataset.sheet);
return sheets[idx]?.name || sheets[0]?.name || data.title || data.path || "";
};
const jobs = panelEls
.map((panel) => {
const cells = {};
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = cellRaw(td); });
return { sheet: sheetNameOf(panel), cells };
})
.filter((job) => Object.keys(job.cells).length);
if (!jobs.length) return;
// #156-A9 — the viewer has no formula engine: a value starting with "=" or
// "@" is stored as TEXT unless the f(x) toggle is on. Say so at save time
// instead of letting the user believe a formula was written (the workbook
// keeps Excel's own computed values as the source of truth).
if (!allowFormula && !isCsv) {
const asText = jobs.reduce(
(n, job) => n + Object.values(job.cells).filter((v) => typeof v === "string" && /^[=@]/.test(v.trim())).length,
0,
);
if (asText) showToast(t("xlsx.formula_stored_as_text", { n: asText }), "info");
}
saveBtn.disabled = true;
// Our own write: the grid already reflects it (dynamic updates), so the
// watcher echo must not trigger a full reload with spinner (utils.js).
window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() };
try {
for (const job of jobs) {
// 409 xlsx_lossy_content → confirm once, then retry with force: true.
// (Also covers a workbook that became lossy while it was open.)
let force = lossyConfirmed;
for (;;) {
try {
await putSheet(job, force);
break;
} catch (err) {
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (!proceed) throw err;
lossyConfirmed = true;
force = true;
continue;
}
throw err;
}
}
}
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
td.classList.remove("xlsx-dirty");
td.dataset.orig = td.textContent;
});
if (activeTd) syncFormulaBar(activeTd);
refreshSaveState();
showToast(t("editor.saved"), "success");
} catch (err) {
refreshSaveState();
// A refused confirmation is a decision, not a failure: neutral toast.
if (err && err.code === "xlsx_lossy_content") {
showToast(t("xlsx.lossy_cancelled"), "info");
} else if (err && err.code === "conflict") {
// #154-A2 — non-blocking: the edits are kept, the user may retry.
showConflict(t("xlsx.conflict_msg"));
} else {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
}
});
// #185 — the Fichier ▸ Télécharger ▸ entry lives in the menu bar now; the
// button id is unchanged, so this stays the single Download implementation
// and the menu just closes.
const downloadOriginalFile = () => {
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
};
// ── #153 A13 — sort / filter / find in the sheet + CSV export ──────────
// ALL of these act on the RENDERED grid only: the workbook is never
// rewritten by a sort or a filter (the save pipeline stays the only write
// path, and the note in xlsx.sort_applied says so).
const visiblePanel = () =>
panelEls.find((p) => p.style.display !== "none") || panelEls[0];
// Sorting reorders
rows by the text of one column. Dirty cells travel
// with their row, so a sort never loses an unsaved edit.
let sortState = null; // { col, dir } on the visible panel
const applySort = (panel, col, dir) => {
const tbody = panel.querySelector(".xlsx-table tbody");
if (!tbody) return;
const rows = [...tbody.querySelectorAll("tr")];
const orderBefore = rowNums(rows);
const numeric = rows.every((tr) => {
const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`);
const v = td ? td.textContent.trim() : "";
return v === "" || !isNaN(Number(v));
});
rows.sort((a, b) => {
const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`);
const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`);
const va = ta ? ta.textContent.trim() : "";
const vb = tb ? tb.textContent.trim() : "";
const cmp = numeric
? (parseFloat(va) || 0) - (parseFloat(vb) || 0)
: va.localeCompare(vb, "fr");
return dir === "asc" ? cmp : -cmp;
});
rows.forEach((tr) => tbody.appendChild(tr));
sortState = { col, dir };
sortResetBtn.style.display = "";
// #156-A10 — the view change is undoable (the workbook stays untouched).
const orderAfter = rowNums(rows);
if (orderBefore.join() !== orderAfter.join()) {
pushHistory({ kind: "order", sheet: panelKey(panel), before: orderBefore, after: orderAfter });
}
refreshPanelFormulas(panel);
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
};
// #155 — Excel-like: clicking a column/row header selects the whole
// column/row. Sorting moved to the context menu (and the sort reset button).
const selectColumn = (panel, col) => {
const rows = panel.querySelectorAll("th.xlsx-rownum").length;
if (!rows || !col) return;
selection = { panel, anchor: { row: 1, col }, focus: { row: rows, col } };
applySelection();
const td = findTd(panel, 1, col);
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = barTextOf(td); }
};
const selectRow = (panel, row) => {
const cols = panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length;
if (!row || !cols) return;
selection = { panel, anchor: { row, col: 1 }, focus: { row, col: cols } };
applySelection();
const td = findTd(panel, row, 1);
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = barTextOf(td); }
};
const wireHeaderSelection = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table || table.dataset.selectWired) return;
table.dataset.selectWired = "1";
// Delegated (not per-TH): headers created later by structure surgery
// resolve their live index, so no stale closure can select the wrong
// column after an insert or a delete.
const paintHeaders = () => {
table.querySelectorAll("thead th:not(.xlsx-corner)").forEach((th) => {
th.style.cursor = "pointer";
th.title = t("xlsx.select_column");
});
table.querySelectorAll("th.xlsx-rownum").forEach((th) => {
th.style.cursor = "pointer";
th.title = t("xlsx.select_row");
});
};
paintHeaders();
// #184 — click a header = whole row/column; DRAG over the headers (or
// Shift+click) extends to several rows / several columns, the way Sheets
// and Excel do. The selection is expressed with the same {anchor, focus}
// model as a range, so every downstream command (copier, effacer, menu)
// keeps working unchanged.
const colCount = () => table.querySelectorAll("thead th:not(.xlsx-corner)").length;
const rowCount = () => panel.querySelectorAll("th.xlsx-rownum").length;
const colIndexOf = (th) => {
const head = table.querySelector("thead tr");
if (!head) return 0;
return [...head.children].indexOf(th);
};
let headerAnchor = null; // { axis, from } — kept after the drag so Shift+click extends
let headerDragging = false;
// `true` entre le `mousedown` et le `click` d'une vraie souris : le clic
// ne refait alors rien (`mousedown` a déjà sélectionné, y compris le
// glisser et le Shift+clic). Sans `mousedown` préalable (clic synthétique,
// activation clavier, événement avalé en amont), le repli `click`
// ci-dessous sélectionne.
let headerPressed = false;
const selectAllFromMargin = () => {
const cols = colCount() || 1;
const rows = rowCount() || 1;
selection = { panel, anchor: { row: 1, col: 1 }, focus: { row: rows, col: cols } };
applySelection();
const head = findTd(panel, 1, 1);
activeTd = head;
if (head) {
formulaInput.disabled = false;
formulaInput.value = barTextOf(head);
}
updateSheetsToolbarStates();
updateMinibar();
};
const cornerFrom = (e) => {
const corner = e.target.closest ? e.target.closest(".xlsx-corner") : null;
return corner && table.contains(corner) ? corner : null;
};
const selectHeaderRange = (axis, from, to) => {
const cols = colCount() || 1;
const rows = rowCount() || 1;
const lo = Math.max(1, Math.min(from, to));
const hi = Math.max(from, to);
if (axis === "row") {
selection = { panel, anchor: { row: lo, col: 1 }, focus: { row: hi, col: cols } };
} else {
selection = { panel, anchor: { row: 1, col: lo }, focus: { row: rows, col: hi } };
}
applySelection();
// The active cell is the first cell of the selected block.
const head = axis === "row"
? findTd(panel, lo, 1)
: findTd(panel, 1, lo);
activeTd = head;
if (head) {
formulaInput.disabled = false;
formulaInput.value = barTextOf(head);
}
updateSheetsToolbarStates();
updateMinibar();
};
// A press on a header edge belongs to the resize gesture, not to the
// selection: the two would otherwise fight over the same mousedown.
// Synthetic events (tests, keyboard activation) carry no pointer position
// (`isTrusted === false`, coords at 0) : sans cette garde, elles tombent
// toujours « près du bord » et la sélection ne se fait jamais.
const nearResizeEdge = (th, axis, e) => {
if (e && e.isTrusted === false) return false;
const r = th.getBoundingClientRect();
const edge = 8; // RESIZE_EDGE_PX
return axis === "col"
? Math.abs(e.clientX - r.right) <= edge
: Math.abs(e.clientY - r.bottom) <= edge;
};
const headerFrom = (e) => {
const colTh = e.target.closest
? e.target.closest(".xlsx-table thead th:not(.xlsx-corner)") : null;
if (colTh && table.contains(colTh)) {
return nearResizeEdge(colTh, "col", e)
? null
: { axis: "col", index: colIndexOf(colTh), th: colTh };
}
const rowTh = e.target.closest ? e.target.closest("th.xlsx-rownum") : null;
if (rowTh && table.contains(rowTh)) {
const n = Number(rowTh.textContent);
return Number.isFinite(n) && n >= 1 && !nearResizeEdge(rowTh, "row", e)
? { axis: "row", index: n, th: rowTh } : null;
}
return null;
};
table.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
if (cornerFrom(e)) {
e.preventDefault();
headerPressed = true;
headerAnchor = null;
headerDragging = false;
selectAllFromMargin();
return;
}
const h = headerFrom(e);
if (!h) return;
e.preventDefault();
headerPressed = true;
// A cell under the pointer may still hold the focus: the selection is
// driven from here, not from the focus event.
if (activeTd && isTypingInCell(activeTd)) { commitEdit(); exitEditing(activeTd); }
if (e.shiftKey && headerAnchor && headerAnchor.axis === h.axis) {
selectHeaderRange(h.axis, headerAnchor.from, h.index);
headerDragging = false;
return;
}
headerAnchor = { axis: h.axis, from: h.index };
headerDragging = true;
selectHeaderRange(h.axis, h.index, h.index);
});
table.addEventListener("mouseover", (e) => {
if (!headerDragging || !headerAnchor) return;
const h = headerFrom(e);
if (!h || h.axis !== headerAnchor.axis) return;
selectHeaderRange(headerAnchor.axis, headerAnchor.from, h.index);
});
const endHeaderDrag = () => { headerDragging = false; };
table.addEventListener("mouseup", endHeaderDrag);
table.addEventListener("mouseleave", endHeaderDrag);
// Repli `click` : quand aucun `mousedown` ne l'a précédé (clic synthétique
// des tests, activation clavier, événement avalé en amont), le clic
// sélectionne — sinon il ne fait que consommer le drapeau, pour ne jamais
// réduire un glisser ou un Shift+clic qui vient d'avoir lieu.
table.addEventListener("click", (e) => {
if (headerPressed) { headerPressed = false; return; }
if (cornerFrom(e)) { selectAllFromMargin(); return; }
const h = headerFrom(e);
if (!h) return;
headerAnchor = { axis: h.axis, from: h.index };
selectHeaderRange(h.axis, h.index, h.index);
});
};
const hiddenRowsOf = (panel) =>
[...panel.querySelectorAll(".xlsx-table tbody tr")]
.filter((tr) => tr.style.display === "none")
.map(rowNumOf);
// Filter: rows whose cell text lacks the needle are hidden. Recorded as ONE
// coalesced undo entry per typing session (#156-A10), so Ctrl+Z clears the
// whole filter instead of replaying it keystroke by keystroke.
const applyFilter = (panel, needle) => {
const before = hiddenRowsOf(panel);
const beforeNeedle = panel.dataset.filterNeedle || "";
const n = needle.trim().toLowerCase();
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
const text = tr.textContent.toLowerCase();
tr.style.display = !n || text.includes(n) ? "" : "none";
});
if (n) sortResetBtn.style.display = "";
panel.dataset.filterNeedle = needle;
if (panel !== visiblePanel()) return; // one entry for the sheet on screen
const after = hiddenRowsOf(panel);
const sheet = panelKey(panel);
const top = undoStack[undoStack.length - 1];
if (top && top.kind === "hidden" && top.sheet === sheet) {
top.after = after;
top.afterNeedle = needle;
redoStack.length = 0;
updateUndoButtons();
return;
}
if (!before.length && !after.length) return; // nothing hidden yet
pushHistory({ kind: "hidden", sheet, before, after, beforeNeedle, afterNeedle: needle });
};
panelEls.forEach(wireHeaderSelection);
// ── Column/row resize by dragging header edges (mouse + touch) ─────────
// Sizes persist through the same locked rewrite as the Mise en forme menu
// (PUT …/xlsx/style col_width/row_height); the re-render applies them from
// the colwidths/rowheights metadata. No undo entry (like the size prompts).
// The drag context is created by the render that starts it (fresh runStyle
// and revision) while pointer routing lives at module level, so re-renders
// never leave stale closures behind.
const RESIZE_EDGE_PX = 8;
const resizeGhost = (hostArea) => {
let g = hostArea.querySelector(".xlsx-resize-ghost");
if (!g) {
g = document.createElement("div");
g.className = "xlsx-resize-ghost";
g.hidden = true;
hostArea.appendChild(g);
}
return g;
};
const moveResizeGhost = (d, pos) => {
// Fixed overlay: client coordinates straight from the pointer.
const g = resizeGhost(d.area);
const grid = d.panel.querySelector(".csv-table-wrapper");
const r = grid ? grid.getBoundingClientRect() : { top: 0, left: 0, height: 0, width: 0 };
if (d.kind === "col") {
g.dataset.kind = "col";
g.style.left = `${pos}px`;
g.style.top = `${r.top}px`;
g.style.height = `${Math.max(0, r.height)}px`;
} else {
g.dataset.kind = "row";
g.style.top = `${pos}px`;
g.style.left = `${r.left}px`;
g.style.width = `${Math.max(0, r.width)}px`;
}
g.hidden = false;
};
const wireResize = (panel) => {
const table = panel.querySelector(".xlsx-table");
if (!table || table.dataset.resizeWired || !editable) return;
table.dataset.resizeWired = "1";
const headRow = table.querySelector("thead tr");
const colAtEdge = (clientX) => {
const ths = [...headRow.querySelectorAll("th:not(.xlsx-corner)")];
for (const th of ths) {
const r = th.getBoundingClientRect();
if (Math.abs(clientX - r.right) <= RESIZE_EDGE_PX) {
return { th, col: [...headRow.children].indexOf(th) };
}
}
return null;
};
const rowAtEdge = (clientY) => {
const nums = [...panel.querySelectorAll("th.xlsx-rownum")];
for (const th of nums) {
const r = th.getBoundingClientRect();
if (Math.abs(clientY - r.bottom) <= RESIZE_EDGE_PX) {
const row = Number(th.textContent);
if (row >= 1) return { th, row };
}
}
return null;
};
const beginDrag = (kind, el, startPx, index) => {
if (activeResizeDrag) return;
const rect = el.getBoundingClientRect();
const sheet = sheets[Number(panel.dataset.sheet)]?.name || "";
const d = {
kind, area, panel, index, sheet,
suppressClick: false,
startPx, startSize: kind === "col" ? rect.width : rect.height,
lastPx: startPx, moved: false,
move(clientX, clientY) {
const pos = this.kind === "col" ? clientX : clientY;
if (Math.abs(pos - this.startPx) > 3) this.moved = true;
this.lastPx = pos;
moveResizeGhost(this, pos);
},
commit() {
const g = this.area.querySelector(".xlsx-resize-ghost");
if (g) g.hidden = true;
if (!this.moved) return;
this.suppressClick = true;
const px = Math.max(1, this.startSize + ((this.lastPx ?? this.startPx) - this.startPx));
if (this.kind === "col") {
const width = Math.min(255, Math.max(3, Math.round((px / 7) * 10) / 10));
runStyle([{ op: "col_width", sheet: this.sheet, col: columnName(this.index), width }]);
} else {
const height = Math.min(409, Math.max(9, Math.round(px * 0.75 * 10) / 10));
runStyle([{ op: "row_height", sheet: this.sheet, row: this.index, height }]);
}
},
cancel() {
const g = this.area.querySelector(".xlsx-resize-ghost");
if (g) g.hidden = true;
},
};
activeResizeDrag = d;
moveResizeGhost(d, startPx);
};
// #180 — the edge header mirrors the resize cursor itself: the column
// headers carry an inline `cursor:pointer` (selection) and the cells a CSS
// `cursor:cell`, both of which would otherwise cover the table-level
// cursor exactly where the icon matters. The table classes below repaint
// the cells through the stylesheet (see `.xlsx-resize-col/row`).
let edgeTh = null;
const setEdgeTh = (th, cursor) => {
if (edgeTh && edgeTh !== th) {
edgeTh.style.cursor = edgeTh.dataset.prevCursor || "";
delete edgeTh.dataset.prevCursor;
edgeTh = null;
}
if (!th) return;
if (edgeTh !== th) {
edgeTh = th;
edgeTh.dataset.prevCursor = edgeTh.style.cursor || "";
}
if (edgeTh.style.cursor !== cursor) edgeTh.style.cursor = cursor;
};
const paintResizeCursor = (kind, th) => {
table.style.cursor = kind === "col" ? "col-resize" : kind === "row" ? "row-resize" : "";
table.classList.toggle("xlsx-resize-col", kind === "col");
table.classList.toggle("xlsx-resize-row", kind === "row");
setEdgeTh(th || null, kind === "col" ? "col-resize" : "row-resize");
};
// Excel / Sheets : on ne redimensionne qu'en prenant le BORD d'un
// en-tête (lettre de colonne / gutter des numéros). Le corps de la grille
// reste réservé à la sélection : auparavant une pression à 3 px d'une
// limite lançait un glisser (ghost + aucune cellule sélectionnée).
// The geometric probe needs a header under the pointer — except when the
// pointer is on the table itself (padding), which names no header; the
// hit test decides, as it does when the engine reports no layout.
const overColHeader = (target) => !!(target && (target === table
|| (target.closest && target.closest("thead th"))));
const overRowHeader = (target) => !!(target && (target === table
|| (target.closest && target.closest("th.xlsx-rownum"))));
table.addEventListener("mousemove", (e) => {
if (activeResizeDrag || !editable || e.buttons) return;
const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null;
if (c) { paintResizeCursor("col", c.th); return; }
const rw = overRowHeader(e.target) ? rowAtEdge(e.clientY) : null;
paintResizeCursor(rw ? "row" : "", rw ? rw.th : null);
});
table.addEventListener("mouseleave", () => { if (!activeResizeDrag) paintResizeCursor("", null); });
table.addEventListener("mousedown", (e) => {
if (!editable || e.button !== 0 || activeResizeDrag) return;
const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null;
const rw = !c && overRowHeader(e.target) ? rowAtEdge(e.clientY) : null;
if (!c && !rw) return;
e.preventDefault();
if (c) beginDrag("col", c.th, e.clientX, c.col);
else beginDrag("row", rw.th, e.clientY, rw.row);
});
table.addEventListener("touchstart", (e) => {
if (!editable || activeResizeDrag || !e.touches || !e.touches[0]) return;
const t0 = e.touches[0];
const c = overColHeader(e.target) ? colAtEdge(t0.clientX) : null;
const rw = !c && overRowHeader(e.target) ? rowAtEdge(t0.clientY) : null;
if (!c && !rw) return; // plain tap / long-press keep their behaviour
e.preventDefault();
e.stopPropagation(); // no long-press menu mid-drag
if (c) beginDrag("col", c.th, t0.clientX, c.col);
else beginDrag("row", rw.th, t0.clientY, rw.row);
}, { passive: false });
// Excel (AutoFit) et Sheets (« Fit to data ») : un double-clic sur le bord
// d'un en-tête ajuste la colonne / la ligne au contenu. Sur une cellule,
// le double-clic reste celui de l'édition (#181-A1) : le geste n'est pris
// ici que lorsqu'il part d'un en-tête.
table.addEventListener("dblclick", (e) => {
if (!editable) return;
const c = overColHeader(e.target) ? colAtEdge(e.clientX) : null;
if (c) { autoFitColumns(panel, [c.col]); return; }
const rw = overRowHeader(e.target) ? rowAtEdge(e.clientY) : null;
if (rw) autoFitRows(panel, [rw.row]);
});
};
panelEls.forEach(wireResize);
// #184 — lay the sheet out like a spreadsheet: columns A → Z, and rows that
// grow up to 1000 as the reader reaches the end (padMoreRows). Done for the
// visible sheet, on tab activation. A truncated sheet keeps its server-driven
// "Charger la suite" window: those rows are real data, not empty padding.
const ensureGridPadded = (panel) => {
if (!panel || isCsv) return;
expandPanelGrid(panel);
applySheetMeta(panel);
refreshPanelFormulas(panel);
const meta = sheets[Number(panel.dataset.sheet)] || {};
const wrapper = panel.querySelector(".csv-table-wrapper");
if (meta.truncated || !wrapper || panel.dataset.gridGrowth === "1") return;
panel.dataset.gridGrowth = "1";
wrapper.addEventListener("scroll", () => {
if (wrapper.scrollTop + wrapper.clientHeight < wrapper.scrollHeight - 120) return;
if (padMoreRows(panel)) applySheetMeta(panel);
}, { passive: true });
};
ensureGridPadded(visiblePanel());
// L'en-tête A–Z glissait sous la toolbar sticky (z-30 > z-3) dès que la page
// défilait — d'autant plus que la barre d'édition a agrandi la toolbar. Le
// `top` sticky de l'en-tête suit donc le recouvrement réel (`--xlsx-stick`),
// mesuré au scroll / redimensionnement et coalescé en rAF.
if (area._xlsxStickCleanup) {
try { area._xlsxStickCleanup(); } catch (_) { /* ignore */ }
area._xlsxStickCleanup = null;
}
const syncStick = () => {
const toolbar = area.querySelector(".xlsx-toolbar");
const panel = typeof visiblePanel === "function" ? visiblePanel() : null;
const wrapper = panel ? panel.querySelector(".csv-table-wrapper") : null;
if (!toolbar || !wrapper || !area.isConnected) return;
const overlap = toolbar.getBoundingClientRect().bottom - wrapper.getBoundingClientRect().top;
area.style.setProperty("--xlsx-stick", `${Math.max(0, Math.round(overlap))}px`);
};
let stickRaf = 0;
const raf = typeof requestAnimationFrame === "function"
? requestAnimationFrame.bind(window)
: (cb) => setTimeout(cb, 0);
const requestStick = () => {
if (stickRaf) return;
stickRaf = raf(() => { stickRaf = 0; syncStick(); });
};
const stickCleanups = [];
const scroller = getContentArea();
if (scroller && typeof scroller.addEventListener === "function") {
const onScroll = () => requestStick();
scroller.addEventListener("scroll", onScroll, { passive: true });
stickCleanups.push(() => scroller.removeEventListener("scroll", onScroll));
}
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
const onWinScroll = () => requestStick();
window.addEventListener("scroll", onWinScroll, { passive: true });
stickCleanups.push(() => window.removeEventListener("scroll", onWinScroll));
}
if (typeof ResizeObserver === "function") {
const toolbar = area.querySelector(".xlsx-toolbar");
if (toolbar) {
const ro = new ResizeObserver(() => requestStick());
ro.observe(toolbar);
stickCleanups.push(() => ro.disconnect());
}
}
area._xlsxStickCleanup = () => {
stickCleanups.forEach((fn) => { try { fn(); } catch (_) { /* ignore */ } });
if (stickRaf && typeof cancelAnimationFrame === "function") {
try { cancelAnimationFrame(stickRaf); } catch (_) { /* ignore */ }
}
stickRaf = 0;
};
requestStick();
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
sortResetBtn.addEventListener("click", () => {
// Full reset: re-render the current file (server truth), clear find too.
sortResetBtn.style.display = "none";
renderXlsxViewer(area, data);
});
// Find-in-sheet: highlight matches, navigate with ↑/↓.
let findMatches = [];
let findIndex = -1;
const findInput = area.querySelector("#xlsx-find-input");
const findCount = area.querySelector("#xlsx-find-count");
let findCase = false;
const clearFind = () => {
findMatches.forEach(({ td, mark }) => {
mark.replaceWith(document.createTextNode(mark.textContent));
td.normalize();
});
findMatches = [];
findIndex = -1;
findCount.textContent = "";
};
// #156-A11 — the find scans EVERY sheet, not just the visible one: the
// counter adds the number of sheets holding a hit and stepping onto a match
// brings its tab forward (the match would otherwise stay invisible).
const runFind = () => {
clearFind();
const needle = findInput.value;
if (!needle.trim()) return;
const hay = findCase ? (s) => s : (s) => s.toLowerCase();
const n = hay(needle);
panelEls.forEach((panel) => {
panel.querySelectorAll(".xlsx-table td").forEach((td) => {
const text = td.textContent;
const pos = hay(text).indexOf(n);
if (pos === -1 || !text) return;
// Wrap the match in a by splitting the cell's first text node at
// the hit (cells are text-first; the cached-value span stays untouched).
const first = td.firstChild;
if (first && first.nodeType === 3) {
const mark = document.createElement("mark");
mark.className = "xlsx-find-hit";
mark.textContent = text.substr(pos, needle.length);
const tail = first.splitText(pos);
tail.splitText(needle.length);
td.replaceChild(mark, tail);
findMatches.push({ td, mark, panel });
}
});
});
if (!findMatches.length) {
findCount.textContent = t("xlsx.find_no_match");
return;
}
focusMatch(0);
};
/** Show the find counter, mentioning the sheets when hits span several. */
const updateFindCount = (index) => {
const count = findMatches.length;
const sheets = new Set(findMatches.map((m) => m.panel)).size;
findCount.textContent = sheets > 1
? t("xlsx.find_count_sheets", { index, count, sheets })
: t("xlsx.find_count", { index, count });
};
const focusMatch = (i) => {
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
findIndex = (i + findMatches.length) % findMatches.length;
const { td, mark, panel } = findMatches[findIndex];
mark.classList.add("xlsx-find-current");
// A hit in another sheet: activate its tab so the match becomes visible.
if (panel.style.display === "none") {
const idx = panel.dataset.sheet;
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x.dataset.sheet === idx));
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
}
// scrollIntoView is missing in JSDOM; guard it (real browsers have it).
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
updateFindCount(findIndex + 1);
};
// One input drives both the highlight (find) and the row filter (A13):
// typing narrows the sheet to the matching rows AND highlights the hits.
findInput.addEventListener("input", () => {
panelEls.forEach((p) => applyFilter(p, findInput.value));
runFind();
});
findInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); }
});
area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1));
area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1));
const caseBtn = area.querySelector("#xlsx-find-case");
caseBtn.addEventListener("click", () => {
findCase = !findCase;
caseBtn.setAttribute("aria-pressed", String(findCase));
caseBtn.classList.toggle("active", findCase);
runFind();
});
// ── #153 A14 — workbook structure menu (sheets, rows, columns) ─────────
// Every action is an explicit user gesture (prompt/confirm) and goes to
// PUT …/xlsx/structure — one locked, atomic rewrite with a backup.
const visibleSheetIndex = () =>
Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0;
/** Inverse of a structure action, or null when it cannot be replayed. */
const inverseAction = (action) => {
switch (action.op) {
case "row_insert":
return { op: "row_delete", sheet: action.sheet, at: action.at, count: action.count || 1 };
case "col_insert":
return { op: "col_delete", sheet: action.sheet, at: action.at, count: action.count || 1 };
case "sheet_rename":
return { op: "sheet_rename", from: action.to, to: action.from };
case "sheet_add":
return String(action.name || "").length && String(action.name).length <= 31
? { op: "sheet_delete", name: action.name }
: null;
case "sheet_duplicate":
return String(action.as || "").length && String(action.as).length <= 31
? { op: "sheet_delete", name: action.as }
: null;
default:
return null; // row_delete / col_delete / sheet_delete drop data
}
};
// ── Dynamic structure application (no re-render) ───────────────────────
// Row/column inserts and deletes are replayed in the DOM (renumber refs,
// shift formula texts like Excel, patch the meta maps) so the grid, focus
// and selection survive. Sheet add/rename/delete/duplicate rebuild only
// their own tab and panel.
const shiftSingleCoord = (ref, op) => {
// Returns the shifted "A1" ref, or null when it falls into a deletion.
const m = /^(\$?)([A-Za-z]{1,3})(\$?)(\d+)$/.exec(String(ref || ""));
if (!m) return null;
let col = colToIndex(m[2]);
let row = Number(m[4]);
if (!col || !(row >= 1)) return null;
if (op.axis === "row") {
if (m[3]) return `${m[1]}${m[2]}${m[3]}${row}`;
if (op.insert) {
if (row >= op.at) row += op.count;
} else if (row >= op.at && row < op.at + op.count) {
return null;
} else if (row >= op.at + op.count) {
row -= op.count;
}
} else {
if (m[1]) return `${m[1]}${m[2]}${m[3]}${row}`;
if (op.insert) {
if (col >= op.at) col += op.count;
} else if (col >= op.at && col < op.at + op.count) {
return null;
} else if (col >= op.at + op.count) {
col -= op.count;
}
}
if (row < 1 || col < 1) return null;
return `${m[1]}${indexToCol(col)}${m[3]}${row}`;
};
const shiftRangeStr = (range, op) => {
// "A1:B2" → shifted range, or null when destroyed by a deletion.
const m = /^([^:]+):([^:]+)$/.exec(String(range || ""));
if (!m) return shiftSingleCoord(range, op);
const a = shiftSingleCoord(m[1], op);
const b = shiftSingleCoord(m[2], op);
if (!a || !b) return null;
// Normalize order (insert order is preserved, deletes clamp it).
const pa = parseRef(a);
const pb = parseRef(b);
if (!pa || !pb) return null;
const r1 = Math.min(pa.row, pb.row);
const r2 = Math.max(pa.row, pb.row);
const c1 = Math.min(pa.col, pb.col);
const c2 = Math.max(pa.col, pb.col);
return `${indexToCol(c1)}${r1}:${indexToCol(c2)}${r2}`;
};
const shiftFormulaRefs = (text, selfSheet, op) => {
// Shift the A1 references of a formula like Excel would, skipping string
// literals, $ locked parts and other sheets' qualifiers. Ranges whose end
// dies in a deletion collapse to #REF!.
const parts = String(text || "").split(/("[^"]*")/g);
for (let i = 0; i < parts.length; i += 2) {
parts[i] = parts[i].replace(
/((?:'[^']+'|[A-Za-z_][\w.]*)!)?(\$?)([A-Za-z]{1,3})(\$?)(\d+)(?!\s*\()/g,
(match, qualifier, colAbs, letters, rowAbs, rowNum) => {
if (qualifier) {
const q = qualifier.slice(0, -1).replace(/^'|'$/g, "");
if (q.toLowerCase() !== String(selfSheet).toLowerCase()) return match;
}
const shifted = shiftSingleCoord(`${colAbs}${letters}${rowAbs}${rowNum}`, op);
if (!shifted) return "#REF!";
return `${qualifier || ""}${shifted}`;
},
);
}
let out = parts.join("");
out = out.replace(/([A-Za-z$0-9]+:#REF!|#REF!:[A-Za-z$0-9]+)/g, "#REF!");
return out;
};
const adjustFormulaTexts = (sheetName, op) => {
panelEls.forEach((panel) => {
panel.querySelectorAll("td[data-cell]").forEach((td) => {
if (!td.dataset.formula) return;
const next = shiftFormulaRefs(td.dataset.formula, sheetName, op);
if (next !== td.dataset.formula) {
td.dataset.formula = next;
delete td.dataset.display;
}
});
});
};
const shiftMetaForOp = (sheetIdx, op) => {
const meta = sheets[sheetIdx];
if (!meta) return;
const shiftKey = (ref) => {
const p = parseRef(ref);
if (!p) return null;
if (op.axis === "row") {
if (op.insert) return p.row >= op.at ? `${indexToCol(p.col)}${p.row + op.count}` : ref;
if (p.row >= op.at && p.row < op.at + op.count) return null;
return p.row >= op.at + op.count ? `${indexToCol(p.col)}${p.row - op.count}` : ref;
}
if (op.insert) return p.col >= op.at ? `${indexToCol(p.col + op.count)}${p.row}` : ref;
if (p.col >= op.at && p.col < op.at + op.count) return null;
return p.col >= op.at + op.count ? `${indexToCol(p.col - op.count)}${p.row}` : ref;
};
["styles", "aligns", "comments", "formats"].forEach((key) => {
const map = meta[key];
if (!map) return;
Object.keys(map).forEach((ref) => {
const next = shiftKey(ref);
if (next === ref || next === null) {
if (next === null) delete map[ref];
return;
}
const value = map[ref];
delete map[ref];
map[next] = value;
});
});
if (op.axis === "col" && meta.colwidths) {
Object.keys(meta.colwidths).forEach((letter) => {
const c = colToIndex(letter);
if (!c) return;
const value = meta.colwidths[letter];
delete meta.colwidths[letter];
const shifted = shiftSingleCoord(`${letter}1`, op);
if (shifted) meta.colwidths[shifted.replace(/[0-9]+$/, "")] = value;
});
}
if (op.axis === "row" && meta.rowheights) {
Object.keys(meta.rowheights).forEach((r) => {
const row = Number(r);
if (!(row >= 1)) return;
const value = meta.rowheights[r];
delete meta.rowheights[r];
const shifted = shiftSingleCoord(`A${row}`, op);
if (shifted) meta.rowheights[String(parseRef(shifted).row)] = value;
});
}
if (meta.merges) {
meta.merges = meta.merges
.map((range) => shiftRangeStr(range, op))
.filter(Boolean);
}
if (meta.freeze && meta.freeze !== "A1") {
const shifted = shiftSingleCoord(meta.freeze, op);
meta.freeze = shifted || "A1";
}
};
const refreshTruncationNote = (panel) => {
const meta = sheets[Number(panel.dataset.sheet)] || {};
const html = truncationNote(meta);
const old = panel.querySelector(".xlsx-truncated");
if (html) {
if (old) {
old.outerHTML = html;
} else {
const tmp = document.createElement("div");
tmp.innerHTML = html;
const node = tmp.firstElementChild;
if (node) {
const wrap = panel.querySelector(".csv-table-wrapper");
if (wrap) wrap.after(node);
else panel.appendChild(node);
}
}
safeCreateIcons();
} else if (old) {
old.remove();
}
};
/** Shift a selection anchor through a structural op (delete-span collapses). */
const shiftSelPoint = (pt, op) => {
if (!pt) return null;
const num = op.axis === "row" ? pt.row : pt.col;
const fixed = op.axis === "row" ? pt.col : pt.row;
if (num === undefined || fixed === undefined) return null;
let n = num;
if (op.insert) {
if (n >= op.at) n += op.count;
} else if (n >= op.at && n < op.at + op.count) {
n = op.at;
} else if (n >= op.at + op.count) {
n -= op.count;
}
if (n < 1) n = 1;
return op.axis === "row" ? { row: n, col: fixed } : { row: fixed, col: n };
};
const blankTd = (ref) => {
const td = document.createElement("td");
td.dataset.cell = ref;
td.setAttribute("role", "gridcell");
setupCell(td);
return td;
};
const applyRowColOp = (sheetIdx, op) => {
const panel = panelEls.find((p) => Number(p.dataset.sheet) === sheetIdx);
const meta = sheets[sheetIdx];
if (!panel || !meta) return;
const tbody = panel.querySelector(".xlsx-table tbody");
const headRow = panel.querySelector(".xlsx-table thead tr");
const nCols = headRow ? headRow.querySelectorAll("th:not(.xlsx-corner)").length : 0;
const rendered = tbody ? tbody.querySelectorAll("tr").length : 0;
adjustFormulaTexts(meta.name, op);
shiftMetaForOp(sheetIdx, op);
const totalKey = op.axis === "row" ? "total_rows" : "total_cols";
meta[totalKey] = Math.max(0, (Number(meta[totalKey]) || 0) + (op.insert ? op.count : -op.count));
if (op.axis === "row" && op.insert && tbody && op.at <= rendered + 1 && nCols > 0) {
const pos = Math.min(op.at, rendered + 1);
const anchor = [...tbody.children].find((tr) => rowNumOf(tr) === pos) || null;
for (let k = 0; k < op.count; k++) {
const tr = document.createElement("tr");
tr.setAttribute("role", "row");
tr.dataset.fresh = "1";
const th = document.createElement("th");
th.className = "xlsx-rownum";
th.setAttribute("role", "rowheader");
th.textContent = String(op.at + k);
th.style.cursor = "pointer";
th.title = t("xlsx.select_row");
tr.appendChild(th);
for (let c = 1; c <= nCols; c++) {
const td = blankTd(`${columnName(c)}${op.at + k}`);
td.setAttribute("role", "gridcell");
tr.appendChild(td);
}
tbody.insertBefore(tr, anchor);
}
// Shift existing rows by reference (DOM order may differ after a sort,
// so recounting positions would corrupt references).
tbody.querySelectorAll("tr:not([data-fresh])").forEach((tr) => {
const rn = rowNumOf(tr);
if (!(rn >= op.at)) return;
const next = rn + op.count;
const th = tr.querySelector("th.xlsx-rownum");
if (th) th.textContent = String(next);
tr.querySelectorAll("td[data-cell]").forEach((td) => {
const p = parseRef(td.dataset.cell);
if (p && p.row >= op.at) td.dataset.cell = `${columnName(p.col)}${p.row + op.count}`;
});
});
tbody.querySelectorAll("tr[data-fresh]").forEach((tr) => { delete tr.dataset.fresh; });
} else if (op.axis === "row" && !op.insert && tbody) {
[...tbody.children].forEach((tr) => {
const rn = rowNumOf(tr);
if (rn >= op.at && rn < op.at + op.count) tr.remove();
});
tbody.querySelectorAll("tr").forEach((tr) => {
const rn = rowNumOf(tr);
if (!(rn >= 1)) return;
if (rn >= op.at + op.count) {
const next = rn - op.count;
const th = tr.querySelector("th.xlsx-rownum");
if (th) th.textContent = String(next);
tr.querySelectorAll("td[data-cell]").forEach((td) => {
const p = parseRef(td.dataset.cell);
if (p && p.row >= op.at + op.count) td.dataset.cell = `${columnName(p.col)}${p.row - op.count}`;
});
}
});
} else if (op.axis === "col" && op.insert && tbody && nCols > 0 && op.at <= nCols + 1) {
for (let k = 0; k < op.count; k++) {
const th = document.createElement("th");
th.textContent = columnName(op.at + k);
th.style.cursor = "pointer";
th.title = t("xlsx.select_column");
th.dataset.fresh = "1";
headRow.insertBefore(th, headRow.children[op.at + k] || null);
}
tbody.querySelectorAll("tr").forEach((tr) => {
const rn = rowNumOf(tr);
for (let k = 0; k < op.count; k++) {
const td = blankTd(`${columnName(op.at + k)}${rn}`);
td.setAttribute("role", "gridcell");
td.dataset.fresh = "1";
const after = [...tr.querySelectorAll("td[data-cell]")].find((cell) => {
const p = parseRef(cell.dataset.cell);
return p && p.col >= op.at && !cell.dataset.fresh;
});
tr.insertBefore(td, after || null);
}
});
// Shift existing references by value (never by position: sorts reorder).
headRow.querySelectorAll("th:not(.xlsx-corner):not([data-fresh])").forEach((th) => {
const idx = colToIndex(th.textContent.trim());
if (idx >= op.at) th.textContent = columnName(idx + op.count);
});
tbody.querySelectorAll("td[data-cell]:not([data-fresh])").forEach((td) => {
const p = parseRef(td.dataset.cell);
if (p && p.col >= op.at) td.dataset.cell = `${columnName(p.col + op.count)}${p.row}`;
});
panel.querySelectorAll("[data-fresh]").forEach((el) => { delete el.dataset.fresh; });
} else if (op.axis === "col" && !op.insert && tbody && nCols > 0) {
const kill = new Set();
for (let c = op.at; c < op.at + op.count; c++) kill.add(c);
[...headRow.children].forEach((th, i) => {
if (i > 0) {
const idx = colToIndex(th.textContent.trim());
if (kill.has(idx)) th.remove();
}
});
tbody.querySelectorAll("tr").forEach((tr) => {
[...tr.querySelectorAll("td[data-cell]")].forEach((td) => {
const p = parseRef(td.dataset.cell);
if (!p) return;
if (kill.has(p.col)) td.remove();
});
});
headRow.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
const idx = colToIndex(th.textContent.trim());
if (idx >= op.at + op.count) th.textContent = columnName(idx - op.count);
});
tbody.querySelectorAll("td[data-cell]").forEach((td) => {
const p = parseRef(td.dataset.cell);
if (p && p.col >= op.at + op.count) td.dataset.cell = `${columnName(p.col - op.count)}${p.row}`;
});
}
// Rebuild spans/freeze/dims from the shifted meta, recompute, tidy UI.
invalidateCellIndex(panel);
applySheetMeta(panel);
applyGridA11y(panel);
refreshTruncationNote(panel);
refreshPanelFormulas(panel);
// The active cell may have been renumbered or removed: re-resolve it.
if (activeTd && !activeTd.isConnected) {
const p = parseRef(cellName(activeTd));
let next = null;
if (p) {
const s = shiftSelPoint({ row: p.row, col: p.col }, op);
if (s) next = findTd(panel, s.row, s.col);
}
setActiveCell(next);
}
if (selection && selection.panel === panel) {
const a = shiftSelPoint(selection.anchor, op);
const f = shiftSelPoint(selection.focus, op);
if (a && f) {
const aTd = findTd(panel, a.row, a.col);
const fTd = findTd(panel, f.row, f.col);
if (aTd && fTd) {
selection.anchor = a;
selection.focus = f;
applySelection();
} else {
setSelection(null);
}
} else {
setSelection(null);
}
if (!selection) {
setActiveCell(null);
const bar = area.querySelector("#xlsx-minibar");
if (bar) bar.hidden = true;
}
}
updateSheetsToolbarStates();
refreshSaveState();
};
/** Activate a sheet tab by index (tab strip + panels + states). */
const activateSheetTab = (idx) => {
area.querySelectorAll(".xlsx-tab").forEach((x) => {
const on = Number(x.dataset.sheet) === idx;
x.classList.toggle("active", on);
x.setAttribute("aria-selected", String(on));
});
panelEls.forEach((p) => { p.style.display = Number(p.dataset.sheet) === idx ? "" : "none"; });
// #184 — pad the sheet that just came on screen (A → Z, 1000 rows).
ensureGridPadded(visiblePanel());
refreshPanelFormulas(visiblePanel());
updateSheetsToolbarStates();
updateMinibar();
requestStick();
};
const reindexSheets = () => {
panelEls.forEach((p, i) => { p.dataset.sheet = String(i); });
area.querySelectorAll(".xlsx-tab").forEach((x, i) => { x.dataset.sheet = String(i); });
};
const blankPanelHtml = (cols, rows) => {
let thead = '
';
for (let c = 1; c <= cols; c++) thead += `
${columnName(c)}
`;
thead += "
";
let body = "";
for (let r = 1; r <= rows; r++) {
body += `
${r}
`;
for (let c = 1; c <= cols; c++) body += `
`;
body += "
";
}
return `
${thead}${body}
`;
};
const wireFreshPanel = (panel) => {
panel.querySelectorAll(".xlsx-table td").forEach(setupCell);
wireResize(panel);
wireLazyRows(panel);
applySheetMeta(panel);
applyGridA11y(panel);
};
const addSheetLocal = (name) => {
const clean = String(name || "").slice(0, 31);
if (!clean || sheets.some((s) => s && s.name === clean)) return;
// Same blank shape as the server (BUG-094): 8 cols × 20 rows.
const idx = sheets.length;
sheets.push({
name: clean, html: "", rows: 20, cols: 8, total_rows: 20, total_cols: 8,
max_rows: 500, max_cols: 40, truncated: false,
styles: {}, aligns: {}, comments: {}, formats: {},
colwidths: {}, rowheights: {}, merges: [], freeze: "",
});
const tabs = area.querySelector(".xlsx-tabs");
if (tabs) {
const tab = document.createElement("button");
tab.className = "xlsx-tab";
tab.dataset.sheet = String(idx);
tab.setAttribute("role", "tab");
tab.setAttribute("aria-selected", "false");
tab.textContent = clean;
tab.addEventListener("click", () => activateSheetTab(idx));
const addBtn = tabs.querySelector("#xlsx-tab-add");
tabs.insertBefore(tab, addBtn || null);
}
const host = area.querySelector(".xlsx-panels");
if (!host) return; // CSV mode has no sheet tabs
const panel = document.createElement("div");
panel.className = "xlsx-panel";
panel.dataset.sheet = String(idx);
panel.style.display = "none";
panel.innerHTML = blankPanelHtml(8, 20);
host.appendChild(panel);
panelEls.push(panel);
wireFreshPanel(panel);
activateSheetTab(idx);
setActiveCell(panel.querySelector('td[data-cell="A1"]'));
};
const renameSheetLocal = (from, to) => {
const clean = String(to || "").slice(0, 31);
const idx = sheets.findIndex((s) => s && s.name === from);
if (idx < 0 || !clean || clean === from) return;
sheets[idx].name = clean;
const tab = area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`);
if (tab) tab.textContent = clean;
[undoStack, redoStack].forEach((stack) => {
stack.forEach((entry) => {
if (entry && entry.sheet === from) entry.sheet = clean;
});
});
// Cross-sheet qualifiers ('Old'!A1) follow the rename like Excel —
// outside string literals, which keep their text.
const esc = String(from).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const both = new RegExp(`(^|[^\\w.$])((?:'${esc}'|${esc})!)`, "g");
const renameInText = (text) => text.split(/("[^"]*")/g).map((part, i) => {
if (i % 2 === 1) return part;
return part.replace(both, (m, pre, q) => {
const quoted = q.startsWith("'");
const needQuotes = /[^A-Za-z0-9_.$]/.test(clean);
const name = needQuotes || quoted ? `'${clean.replace(/'/g, "''")}'` : clean;
return `${pre}${name}!`;
});
}).join("");
panelEls.forEach((panel) => {
panel.querySelectorAll("td[data-cell]").forEach((td) => {
if (!td.dataset.formula) return;
const next = renameInText(td.dataset.formula);
if (next !== td.dataset.formula) {
td.dataset.formula = next;
delete td.dataset.display;
}
});
});
};
const deleteSheetLocal = (name) => {
const idx = sheets.findIndex((s) => s && s.name === name);
if (idx < 0 || sheets.length <= 1) return;
area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`)?.remove();
const panel = panelEls[idx];
if (panel) panel.remove();
sheets.splice(idx, 1);
panelEls.splice(idx, 1);
reindexSheets();
[undoStack, redoStack].forEach((stack) => {
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i] && stack[i].sheet === name) stack.splice(i, 1);
}
});
updateUndoButtons();
if (activeTd && !activeTd.isConnected) setActiveCell(null);
if (selection && !selection.panel.isConnected) {
selection = null;
const bar = area.querySelector("#xlsx-minibar");
if (bar) bar.hidden = true;
}
activateSheetTab(0);
};
const duplicateSheetLocal = (name, as) => {
const clean = String(as || "").slice(0, 31);
const srcIdx = sheets.findIndex((s) => s && s.name === name);
if (srcIdx < 0 || !clean || sheets.some((s) => s && s.name === clean)) return;
const srcPanel = panelEls.find((p) => Number(p.dataset.sheet) === srcIdx);
if (!srcPanel) return;
const idx = sheets.length;
const meta = JSON.parse(JSON.stringify(sheets[srcIdx] || {}));
meta.name = clean;
sheets.push(meta);
const tabs = area.querySelector(".xlsx-tabs");
if (tabs) {
const tab = document.createElement("button");
tab.className = "xlsx-tab";
tab.dataset.sheet = String(idx);
tab.setAttribute("role", "tab");
tab.setAttribute("aria-selected", "false");
tab.textContent = clean;
tab.addEventListener("click", () => activateSheetTab(idx));
const addBtn = tabs.querySelector("#xlsx-tab-add");
tabs.insertBefore(tab, addBtn || null);
}
const clone = srcPanel.cloneNode(true);
clone.dataset.sheet = String(idx);
clone.style.display = "none";
clone.querySelector("table")?.removeAttribute("data-select-wired");
clone.querySelector("table")?.removeAttribute("data-resize-wired");
clone.querySelector(".xlsx-load-more")?.remove();
clone.querySelectorAll("td.xlsx-dirty").forEach((td) => {
td.classList.remove("xlsx-dirty");
td.dataset.orig = td.textContent;
});
clone.querySelectorAll("td.xlsx-active").forEach((td) => td.classList.remove("xlsx-active"));
clone.querySelectorAll("td.xlsx-selected").forEach((td) => td.classList.remove("xlsx-selected"));
const host = area.querySelector(".xlsx-panels");
if (!host) return;
host.appendChild(clone);
panelEls.push(clone);
wireFreshPanel(clone);
activateSheetTab(idx);
setActiveCell(clone.querySelector('td[data-cell="A1"]'));
};
const applyStructureLocal = (actions) => {
(actions || []).forEach((action) => {
if (!action || typeof action !== "object") return;
const op = action.op;
if (op === "row_insert" || op === "row_delete" || op === "col_insert" || op === "col_delete") {
const idx = sheets.findIndex((s) => s && s.name === action.sheet);
if (idx < 0) return;
const at = Number(action.at) || 1;
const count = Math.max(1, Number(action.count) || 1);
applyRowColOp(idx, {
axis: op.startsWith("row") ? "row" : "col",
at, count, insert: op.endsWith("insert"),
});
} else if (op === "sheet_add") {
addSheetLocal(action.name);
} else if (op === "sheet_rename") {
renameSheetLocal(action.from, action.to);
} else if (op === "sheet_delete") {
deleteSheetLocal(action.name);
} else if (op === "sheet_duplicate") {
duplicateSheetLocal(action.name, action.as);
}
});
refreshAllFormulas();
updateSheetsToolbarStates();
refreshSaveState();
};
const putStructure = async (actions, force = false, { quiet = false, record = true } = {}) => {
window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() };
const res = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/structure?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ actions, force, if_match: revision }),
},
);
keepRevision(res);
if (!quiet) showToast(t("xlsx.structure_saved"), "success");
// #156-A10 — a fully reversible batch goes on the undo stack.
if (record) {
const inverses = actions.map(inverseAction);
if (inverses.every(Boolean)) {
pushHistory({ kind: "structure", before: inverses, after: actions });
}
}
// Dynamic: replay the batch in the DOM — grid, focus and selection
// survive (no full re-render).
applyStructureLocal(actions);
return res;
};
const structureError = async (err, retryActions) => {
if (err && err.code === "xlsx_lossy_content") {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (proceed) return putStructure(retryActions, true); // re-emitted with force
showToast(t("xlsx.lossy_cancelled"), "info");
return null;
}
if (err && err.code === "conflict") {
// #156-A12 — a concurrent writer: keep the user's edits, offer a retry.
showConflict(t("xlsx.conflict_msg"));
return null;
}
showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error");
return null;
};
// #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une
// feuille » item, right where the tabs are (Excel-like).
const tabAddBtn = area.querySelector("#xlsx-tab-add");
if (tabAddBtn) tabAddBtn.addEventListener("click", async () => {
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
// Structure menu: built on demand, positioned under the button.
// (.csv / read-only workbooks have no such button — the menu block above
// is skipped for them.)
// Anchor a toolbar menu under its toggle button (clamped to the toolbar
// horizontally; dropped above the button when it would overflow the viewport).
const anchorUnder = (menu, toolbar, btn) => {
const toolRect = toolbar.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
const w = menu.offsetWidth || 240;
const h = menu.offsetHeight || 0;
// With zeroed rects (JSDOM) this settles on 4px/4px — still assertable.
const left = Math.max(4, Math.min(btnRect.left - toolRect.left, Math.max(4, toolRect.width - w - 4)));
let top = btnRect.bottom - toolRect.top + 4;
if (h && toolRect.top + top + h > vh - 8) {
top = Math.max(4, btnRect.top - toolRect.top - h - 4);
}
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
};
// #179 — tracked dismissal of the structure menu (outside, focus loss…).
let dismissStructureMenu = null;
const structureBtn = area.querySelector("#xlsx-structure-btn");
if (structureBtn) structureBtn.addEventListener("click", (e) => {
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; return; }
const idx = visibleSheetIndex();
const sheetName = sheets[idx]?.name || "";
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu";
menu.setAttribute("role", "menu");
// #179 — representative icon per entry; destructive ones read as danger.
const item = (iconName, label, fn, danger = false) => {
const b = document.createElement("button");
b.type = "button";
b.className = `btn-action xlsx-structure-item${danger ? " xlsx-context-danger" : ""}`;
b.setAttribute("role", "menuitem");
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = label;
b.appendChild(lab);
b.addEventListener("click", () => {
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; }
fn();
});
menu.appendChild(b);
};
item("file-plus", t("xlsx.sheet_add"), async () => {
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item("pencil", t("xlsx.sheet_rename"), async () => {
const to = await showPrompt({
title: t("xlsx.sheet_rename"),
message: t("xlsx.structure_prompt_rename"),
value: sheetName,
});
if (!to || to === sheetName) return;
const actions = [{ op: "sheet_rename", from: sheetName, to }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item("copy", t("xlsx.sheet_duplicate"), async () => {
const as = await showPrompt({
title: t("xlsx.sheet_duplicate"),
message: t("xlsx.structure_prompt_add"),
value: `${sheetName} (copie)`,
});
if (!as) return;
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item("trash-2", t("xlsx.sheet_delete"), async () => {
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
const okDelete = await showConfirm({
title: t("xlsx.sheet_delete"),
message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }),
confirmLabel: t("xlsx.sheet_delete"),
danger: true,
});
if (!okDelete) return;
const actions = [{ op: "sheet_delete", name: sheetName }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}, true);
if (parsed) {
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
item("rows", t("xlsx.row_insert"), async () => {
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item("trash-2", t("xlsx.row_delete"), async () => {
const okRow = await showConfirm({
title: t("xlsx.row_delete"),
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
confirmLabel: t("xlsx.row_delete"),
danger: true,
});
if (!okRow) return;
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}, true);
item("columns", t("xlsx.col_insert"), async () => {
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item("trash-2", t("xlsx.col_delete"), async () => {
const okCol = await showConfirm({
title: t("xlsx.col_delete"),
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
confirmLabel: t("xlsx.col_delete"),
danger: true,
});
if (!okCol) return;
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}, true);
}
enableArrowNav(menu, ".xlsx-structure-item");
const structureToolbar = e.target.closest(".xlsx-toolbar");
structureToolbar.appendChild(menu);
anchorUnder(menu, structureToolbar, structureBtn);
safeCreateIcons();
structureBtn.setAttribute("aria-expanded", "true");
// #179 — close on outside click, focus loss, Escape, scroll, resize.
dismissStructureMenu = trackDismissable(menu, {
toggle: structureBtn,
onClose: () => {
dismissStructureMenu = null;
structureBtn.setAttribute("aria-expanded", "false");
menu.remove();
},
});
});
// ── #156-A11 — sortie : CSV / Markdown / HTML / impression ────────────
// The four formats all read the SAME grid: the selection's block when a
// multi-cell range is active (this tranche l'écart doc/code de #153 A13,
// qui annonçait « export de la sélection »), the visible sheet otherwise.
// Values are the DISPLAYED ones — the cached formula shadow is a tooltip,
// never data. Rows follow the render (post-trim/post-lazy-load) so what is
// exported is exactly what the user sees.
/** The whole visible sheet as a matrix of displayed values. */
// #184 — the grid is laid out generously (columns A → Z, rows grown on
// demand), so the tail of empty rows/columns is LAYOUT, not data: the export
// carries the used block, the way a spreadsheet export does. Without this a
// two-column sheet exported 26 columns of blanks.
const isBlankCell = (v) => String(v == null ? "" : v).trim() === "";
const sheetMatrix = (panel) => {
const rows = [...panel.querySelectorAll(".xlsx-table tbody tr")].map((tr) =>
[...tr.querySelectorAll("td")].map((td) => cellTextOf(td)),
);
while (rows.length && rows[rows.length - 1].every(isBlankCell)) rows.pop();
let width = 0;
rows.forEach((r) => {
for (let c = r.length - 1; c >= 0; c--) {
if (!isBlankCell(r[c])) { if (c + 1 > width) width = c + 1; break; }
}
});
return rows.map((r) => r.slice(0, width));
};
/** What an export should carry, plus the scope/title to name the file. */
const exportPayload = () => {
const panel = visiblePanel();
const b = selectionBounds();
const selected = Boolean(
b && selection && selection.panel === panel && (b.r2 > b.r1 || b.c2 > b.c1),
);
const base = (sheets[Number(panel.dataset.sheet)]?.name || data.title || data.path || "feuille")
.replace(/\.[^.]+$/, "");
return {
rows: selected ? selectionMatrix() : sheetMatrix(panel),
selected,
// « Fruits-A1C3 » — a filename-safe version of the range address.
name: selected ? `${base}-${rangeLabel().replace(/[:$]/g, "")}` : base,
scope: t(selected ? "xlsx.export_scope_selection" : "xlsx.export_scope_sheet"),
};
};
const downloadText = (filename, text, mime) => {
const blob = new Blob([text], { type: mime });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
};
const toCsv = (rows) =>
rows
.map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";"))
.join("\n");
const toMarkdown = (rows) => {
if (!rows.length) return "";
const head = rows[0];
return [
`| ${head.join(" | ")} |`,
`|${head.map(() => "---").join("|")}|`,
...rows.slice(1).map((r) => `| ${r.join(" | ")} |`),
].join("\n");
};
const toHtml = (rows, title) => `
${escapeHtml(title)}
${rows
.map((r) => `
${r.map((v) => `
${escapeHtml(v)}
`).join("")}
`)
.join("")}
`;
const exportSheetCsv = () => {
const { rows, name, scope } = exportPayload();
downloadText(`${name}.csv`, `\uFEFF${toCsv(rows)}`, "text/csv;charset=utf-8");
showToast(t("xlsx.export_done", { scope }), "success");
};
const exportDocument = (kind) => {
const { rows, name, scope } = exportPayload();
if (kind === "md") {
downloadText(`${name}.md`, toMarkdown(rows), "text/markdown;charset=utf-8");
} else {
downloadText(`${name}.html`, toHtml(rows, name), "text/html;charset=utf-8");
}
showToast(t("xlsx.export_done", { scope }), "success");
};
// Print renders the sheet in a hidden 0×0 iframe so the app chrome (toolbar,
// banners, tabs, inspector) never reaches the paper — the frame carries its
// own standalone document. The previous frame is dropped first: at most one
// ever lingers in the page.
const printSheet = () => {
const { rows, name } = exportPayload();
document.querySelectorAll("body > iframe.xlsx-print-frame").forEach((f) => f.remove());
document.body.classList.add("xlsx-print");
const frame = document.createElement("iframe");
frame.className = "xlsx-print-frame";
frame.setAttribute("aria-hidden", "true");
document.body.appendChild(frame);
try {
const doc = frame.contentDocument;
if (doc) {
doc.open();
doc.write(toHtml(rows, name));
doc.close();
const win = frame.contentWindow;
if (win && typeof win.print === "function") win.print();
} else {
// No same-origin contentDocument (older engines): keep the document
// reachable for a manual print instead of losing the export.
frame.setAttribute("srcdoc", toHtml(rows, name));
if (typeof window.print === "function") window.print();
}
} finally {
document.body.classList.remove("xlsx-print");
}
};
// #185 — the file menu moved to the menu bar (frontend/js/xlsx/menus.js);
// its `[data-xlsx-export]` entries and `#xlsx-download-btn` /
// `#xlsx-csv-btn` ids are wired by the generic menu handler below.
// ── #156-A8 — mise en forme en écriture ──────────────────────────────────
// Styles, fusions, largeurs, hauteurs et volets figés partent en une seule
// réécriture verrouillée/atomique (PUT …/xlsx/style), comme les éditions de
// cellules et la structure. Les couleurs sont **choisies** (sélecteur natif),
// jamais codées en dur : le champ `style` ne porte que des valeurs de l'appel.
const selectionRange = () => {
const b = selectionBounds();
if (b && selection && selection.panel === visiblePanel()) return rangeLabel();
return cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null) || "A1";
};
// ── Dynamic style application (no re-render) ──────────────────────────
// Mirrors the server fragment mapping so the grid matches the file without
// a round-trip: patch the sheet meta maps, then reuse applySheetMeta().
const splitDecls = (css) => {
const parts = [];
String(css || "").split(";").forEach((p) => {
const i = p.indexOf(":");
if (i > 0) parts.push([p.slice(0, i).trim(), p.slice(i + 1).trim()]);
});
return parts;
};
const setDecl = (parts, key, value) => {
const i = parts.findIndex((p) => p[0] === key);
if (value === null || value === undefined || value === "") {
if (i >= 0) parts.splice(i, 1);
} else if (i >= 0) {
parts[i][1] = value;
} else {
parts.push([key, value]);
}
};
const normHex = (v) => {
const s = String(v || "").trim().replace(/^#/, "");
return /^[0-9a-fA-F]{6}$/.test(s) ? `#${s.toLowerCase()}` : null;
};
const patchCellFragment = (meta, ref, style) => {
const styles = meta.styles || (meta.styles = {});
const aligns = meta.aligns || (meta.aligns = {});
const formats = meta.formats || (meta.formats = {});
const parts = splitDecls(styles[ref] || "");
if ("bold" in style) setDecl(parts, "font-weight", style.bold ? "600" : null);
if ("italic" in style) setDecl(parts, "font-style", style.italic ? "italic" : null);
if ("underline" in style || "strike" in style) {
const cur = new Set((((parts.find((p) => p[0] === "text-decoration") || [])[1]) || "").split(" ").filter(Boolean));
// Baseline from the td when the map has no entry yet (fresh styling).
if ("underline" in style) {
if (style.underline) cur.add("underline"); else cur.delete("underline");
}
if ("strike" in style) {
if (style.strike) cur.add("line-through"); else cur.delete("line-through");
}
setDecl(parts, "text-decoration", cur.size ? [...cur].join(" ") : null);
}
if ("font_color" in style) {
const hex = style.font_color ? normHex(style.font_color) : null;
setDecl(parts, "color", hex);
}
if ("fill_color" in style) {
const hex = style.fill_color ? normHex(style.fill_color) : null;
setDecl(parts, "background", hex);
}
if ("font_size" in style) {
const n = Number(style.font_size);
setDecl(parts, "font-size", Number.isFinite(n) && n !== 11 ? `${Number.isInteger(n) ? n : Math.round(n * 100) / 100}pt` : null);
}
if ("align" in style) {
if (style.align === "left") delete aligns[ref];
else aligns[ref] = style.align;
}
if ("valign" in style) {
setDecl(parts, "vertical-align", style.valign === "middle" ? "middle" : style.valign);
}
if ("wrap" in style) setDecl(parts, "white-space", style.wrap ? "normal" : "nowrap");
if ("rotation" in style) {
const r = Number(style.rotation) || 0;
if (r === 90) {
setDecl(parts, "writing-mode", "vertical-rl");
setDecl(parts, "transform", null);
} else if (r === 180) {
setDecl(parts, "writing-mode", "vertical-rl");
setDecl(parts, "transform", "scale(-1)");
} else if (r) {
// Diagonal (ex. ±45° du menu Rotation) : le CSS tourne en sens
// horaire, Excel en anti-horaire — signe inversé, comme le lecteur.
setDecl(parts, "writing-mode", null);
setDecl(parts, "transform", `rotate(${-r}deg)`);
} else {
setDecl(parts, "writing-mode", null);
setDecl(parts, "transform", null);
}
}
if ("border" in style || "border_style" in style || "border_color" in style) {
const which = style.border || "all";
if (which === "none") {
["border-left", "border-right", "border-top", "border-bottom"].forEach((k) => setDecl(parts, k, null));
} else {
const st = { medium: "2px", thick: "3px" }[style.border_style] || "1px";
const bs = ["dashed", "dotted", "double"].includes(style.border_style) ? style.border_style : "solid";
const hex = normHex(style.border_color) || "#000000";
["border-left", "border-right", "border-top", "border-bottom"].forEach((k) => {
setDecl(parts, k, `${st} ${bs} ${hex}`);
});
}
}
if ("number_format" in style) {
const fmt = style.number_format || "General";
if (fmt === "General" || fmt === "@") delete formats[ref];
else formats[ref] = fmt;
setDecl(parts, "font-family", (fmt === "General" || fmt === "@") ? null : "'JetBrains Mono',monospace");
}
const css = parts.map(([k, v]) => `${k}:${v}`).join(";");
if (css) styles[ref] = css;
else delete styles[ref];
};
const expandRangeRefs = (range) => {
const m = /^\$?([A-Za-z]{1,3})\$?(\d+)(?::\$?([A-Za-z]{1,3})\$?(\d+))?$/.exec(String(range || "").trim());
if (!m) return [];
const c1 = colToIndex(m[1]);
const r1 = Number(m[2]);
const c2 = m[3] ? colToIndex(m[3]) : c1;
const r2 = m[4] ? Number(m[4]) : r1;
if (!c1 || !c2 || !(r1 >= 1) || !(r2 >= 1)) return [];
const out = [];
for (let r = Math.min(r1, r2); r <= Math.max(r1, r2); r++) {
for (let c = Math.min(c1, c2); c <= Math.max(c1, c2); c++) {
out.push(`${indexToCol(c)}${r}`);
if (out.length >= 10000) return out;
}
}
return out;
};
/** Patch the sheet meta maps from style ops, then repaint (no re-render). */
const applyStyleOpsLocal = (sheetIdx, ops) => {
const meta = sheets[sheetIdx];
if (!meta) return;
const panel = panelEls.find((p) => Number(p.dataset.sheet) === sheetIdx);
ops.forEach((op) => {
if (!op || typeof op !== "object") return;
if (op.op === "cell") {
const refs = expandRangeRefs(op.range || op.cell || "");
refs.forEach((ref) => patchCellFragment(meta, ref, op.style || {}));
} else if (op.op === "merge") {
meta.merges = meta.merges || [];
if (op.range && !meta.merges.includes(op.range)) meta.merges.push(op.range);
} else if (op.op === "unmerge") {
meta.merges = (meta.merges || []).filter((m) => m !== op.range);
} else if (op.op === "col_width") {
const col = String(op.col || "").trim().toUpperCase();
meta.colwidths = meta.colwidths || {};
if (col) meta.colwidths[col] = Number(op.width);
} else if (op.op === "row_height") {
const row = Number(op.row);
meta.rowheights = meta.rowheights || {};
if (row >= 1) meta.rowheights[String(row)] = Number(op.height);
} else if (op.op === "freeze") {
meta.freeze = op.cell ? String(op.cell).toUpperCase() : "";
} else if (op.op === "comment_set") {
meta.comments = meta.comments || {};
if (op.ref && op.text) meta.comments[String(op.ref).toUpperCase()] = String(op.text).slice(0, 500);
} else if (op.op === "comment_clear") {
if (meta.comments) delete meta.comments[String(op.ref || "").toUpperCase()];
}
});
if (panel) {
applySheetMeta(panel);
refreshPanelFormulas(panel);
}
updateSheetsToolbarStates();
};
const putStyle = async (ops, force = false) => {
window.__xlsxLastOwnWrite = { vault: data.vault, path: data.path, at: Date.now() };
const res = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/style?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ops, force, if_match: revision }),
},
);
keepRevision(res);
showToast(t("xlsx.format_applied"), "success");
// Dynamic: patch the meta maps and repaint in place — the grid, focus
// and selection survive (no full re-render).
const bySheet = new Map();
ops.forEach((op) => {
if (!op || typeof op !== "object") return;
const idx = sheets.findIndex((s) => s && s.name === op.sheet);
if (idx < 0) return;
if (!bySheet.has(idx)) bySheet.set(idx, []);
bySheet.get(idx).push(op);
});
bySheet.forEach((sheetOps, idx) => applyStyleOpsLocal(idx, sheetOps));
return res;
};
const styleError = async (err, retryOps) => {
if (err && err.code === "xlsx_lossy_content") {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (proceed) return putStyle(retryOps, true); // re-emitted with force
showToast(t("xlsx.lossy_cancelled"), "info");
return null;
}
if (err && err.code === "conflict") {
showConflict(t("xlsx.conflict_msg"));
return null;
}
showToast(`${t("xlsx.format_error")}: ${err.message || err}`, "error");
return null;
};
const runStyle = async (ops) => {
try { await putStyle(ops); } catch (err) { await styleError(err, ops); }
};
// The bold/italic toggles read the style the reader applied (A15 keeps the
// font fragments inline), so a second click removes the effect.
const activeStyleCell = () =>
(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
const hasBold = () => {
const td = activeStyleCell();
return Boolean(td && (td.style.fontWeight === "600" || td.style.fontWeight === "bold"));
};
const hasItalic = () => {
const td = activeStyleCell();
return Boolean(td && td.style.fontStyle === "italic");
};
const hasUnderline = () => {
const td = activeStyleCell();
return Boolean(td && (td.style.textDecoration || "").includes("underline"));
};
const hasStrike = () => {
const td = activeStyleCell();
return Boolean(td && (td.style.textDecoration || "").includes("line-through"));
};
// #179 — tracked dismissal of the format menu (outside, focus loss…).
let dismissFormatMenu = null;
const formatBtn = area.querySelector("#xlsx-format-btn");
if (formatBtn) formatBtn.addEventListener("click", () => {
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; return; }
const sheetName = sheets[visibleSheetIndex()]?.name || "";
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu xlsx-format-menu";
menu.setAttribute("role", "menu");
// #179 — representative icon per entry.
const item = (iconName, label, fn) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn-action xlsx-structure-item";
b.setAttribute("role", "menuitem");
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = label;
b.appendChild(lab);
b.addEventListener("click", () => {
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; }
fn();
});
menu.appendChild(b);
};
const sep = () => menu.appendChild(
Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }),
);
const cellStyle = (style) => () => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style },
]);
const numFormat = (fmt) => cellStyle({ number_format: fmt });
item("bold", t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
item("italic", t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
item("underline", t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
item("strikethrough", t("xlsx.format_strike"), cellStyle({ strike: !hasStrike() }));
item("remove-formatting", t("xlsx.format_clear"), cellStyle({
bold: false, italic: false, underline: false,
font_color: "", fill_color: "", align: "left", number_format: "General",
}));
sep();
item("align-left", t("xlsx.format_align_left"), cellStyle({ align: "left" }));
item("align-center", t("xlsx.format_align_center"), cellStyle({ align: "center" }));
item("align-right", t("xlsx.format_align_right"), cellStyle({ align: "right" }));
sep();
// #183 — colours open the same Sheets-like panel as the ribbon (the native
// input is only driven by its « + » / crayon buttons).
const colour = (iconName, label, kind) => {
const row = document.createElement("div");
row.className = "xlsx-format-colour";
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
row.appendChild(ic);
}
const span = document.createElement("span");
span.textContent = label;
row.appendChild(span);
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "xlsx-format-colour-btn";
trigger.dataset.colourMenu = kind;
trigger.title = label;
trigger.setAttribute("aria-label", label);
trigger.setAttribute("aria-haspopup", "dialog");
const dot = document.createElement("span");
dot.className = "xlsx-format-colour-dot";
dot.dataset.for = kind;
dot.setAttribute("aria-hidden", "true");
trigger.appendChild(dot);
row.appendChild(trigger);
menu.appendChild(row);
return trigger;
};
colour("palette", t("xlsx.format_font_color"), "font");
colour("paint-bucket", t("xlsx.format_fill_color"), "fill");
// #183 — the two colour rows open the same Sheets-like panel as the ribbon.
const openMenuColourPanel = (btn) => {
const kind = btn.dataset.colourMenu === "fill" ? "fill" : "font";
const td = activeStyleCell();
const value = normHex(kind === "fill"
? (td ? cssToHex(td.style.backgroundColor || td.style.background) : null)
: (td ? cssToHex(td.style.color) : null)) || "";
// Never two panels at once (the ribbon one may still be open).
area.querySelectorAll(".xlsx-colour-panel[data-xlsx-colour-panel]").forEach((p) => p.remove());
const { el, sync } = createColourPicker({
kind,
value,
custom: value || "",
onPick: (colour) => {
remove();
runStyle([{
op: "cell", sheet: sheetName, range: selectionRange(),
style: kind === "fill" ? { fill_color: colour } : { font_color: colour },
}]);
},
onReset: () => {
remove();
runStyle([{
op: "cell", sheet: sheetName, range: selectionRange(),
style: kind === "fill" ? { fill_color: "" } : { font_color: "" },
}]);
},
onCustom: () => {
// Arbitrary colours keep using the native picker: the panel offers
// the 80 curated swatches + the 8 standard ones.
const native = document.createElement("input");
native.type = "color";
native.value = value || "#000000";
native.setAttribute("data-xlsx-native", kind);
native.style.cssText = "position:absolute;opacity:0;pointer-events:none";
native.addEventListener("change", () => {
const picked = native.value;
const dot = menu.querySelector(`.xlsx-format-colour-dot[data-for="${kind}"]`);
if (dot) dot.style.background = picked;
native.remove();
remove();
runStyle([{
op: "cell", sheet: sheetName, range: selectionRange(),
style: kind === "fill" ? { fill_color: picked } : { font_color: picked },
}]);
});
menu.appendChild(native);
if (typeof native.click === "function") native.click();
},
onUnavailable: (label) => showToast(t("xlsx.colour_unavailable", { feature: label }), "info"),
});
const remove = () => el.remove();
menu.appendChild(el);
const dot = menu.querySelector(`.xlsx-format-colour-dot[data-for="${kind}"]`);
if (dot && value) dot.style.background = value;
safeCreateIcons();
sync(value);
};
menu.querySelectorAll("[data-colour-menu]").forEach((trigger) => {
trigger.addEventListener("click", (e) => { e.preventDefault(); openMenuColourPanel(trigger); });
});
sep();
item("type", t("xlsx.format_num_general"), numFormat("General"));
item("hash", t("xlsx.format_num_number"), numFormat("0.00"));
item("percent", t("xlsx.format_num_percent"), numFormat("0.0%"));
item("euro", t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
item("calendar", t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
item("pilcrow", t("xlsx.format_num_text"), numFormat("@"));
sep();
item("combine", t("xlsx.format_merge"), () => {
const b = selectionBounds();
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
showToast(t("xlsx.format_merge_needs_range"), "info");
return;
}
runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]);
});
item("ungroup", t("xlsx.format_unmerge"), () => {
runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]);
});
sep();
item("snowflake", t("xlsx.format_freeze"), () => {
runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]);
});
item("sun", t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
sep();
item("move-horizontal", t("xlsx.format_col_width"), () => promptColumnWidth());
item("maximize-2", t("xlsx.format_col_fit"), () => autoFitSelection("col"));
item("move-vertical", t("xlsx.format_row_height"), () => promptRowHeight());
item("maximize-2", t("xlsx.format_row_fit"), () => autoFitSelection("row"));
enableArrowNav(menu, ".xlsx-structure-item");
const formatToolbar = formatBtn.closest(".xlsx-toolbar");
formatToolbar.appendChild(menu);
anchorUnder(menu, formatToolbar, formatBtn);
safeCreateIcons();
formatBtn.setAttribute("aria-expanded", "true");
// #179 — close on outside click, focus loss, Escape, scroll, resize.
dismissFormatMenu = trackDismissable(menu, {
toggle: formatBtn,
onClose: () => {
dismissFormatMenu = null;
formatBtn.setAttribute("aria-expanded", "false");
menu.remove();
},
});
});
// ══ Sheets-like ribbon wiring ──────────────────────────────────────────
// Every control below reuses the locked PUT …/xlsx/style rewrite (runStyle)
// or existing viewer flows — no new backend route.
const styleSheetName = () => sheets[visibleSheetIndex()]?.name || "";
const runCellStyle = (style) => runStyle([
{ op: "cell", sheet: styleSheetName(), range: selectionRange(), style },
]);
const sheetFormatsOf = () => {
const meta = sheets[visibleSheetIndex()] || {};
return meta.formats || {};
};
/** Tracked dropdown menu for ribbon buttons (borders, aligns, rotation, ⋮). */
const trackedRibbonMenus = new Map();
const openRibbonMenu = (btn, items) => {
const prev = trackedRibbonMenus.get(btn);
if (prev) { prev(); trackedRibbonMenus.delete(btn); return; }
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu xlsx-ribbon-menu";
menu.setAttribute("role", "menu");
items.forEach((entry) => {
if (entry.sep) {
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
return;
}
const b = document.createElement("button");
b.type = "button";
b.className = `btn-action xlsx-structure-item${entry.danger ? " xlsx-context-danger" : ""}`;
b.setAttribute("role", "menuitem");
if (entry.icon) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", entry.icon);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = entry.label;
b.appendChild(lab);
b.addEventListener("click", () => {
const d = trackedRibbonMenus.get(btn);
if (d) { d(); trackedRibbonMenus.delete(btn); }
entry.onPick();
});
menu.appendChild(b);
});
enableArrowNav(menu, ".xlsx-structure-item");
const toolbar = btn.closest(".xlsx-toolbar");
toolbar.appendChild(menu);
anchorUnder(menu, toolbar, btn);
safeCreateIcons();
btn.setAttribute("aria-expanded", "true");
trackedRibbonMenus.set(btn, trackDismissable(menu, {
toggle: btn,
onClose: () => {
trackedRibbonMenus.delete(btn);
btn.setAttribute("aria-expanded", "false");
menu.remove();
},
}));
};
const searchBtn = area.querySelector("#xs-search");
if (searchBtn) searchBtn.addEventListener("click", () => {
const fi = area.querySelector("#xlsx-find-input");
if (!fi) return;
fi.scrollIntoView({ block: "nearest" });
fi.focus();
});
const printBtn = area.querySelector("#xs-print");
if (printBtn) printBtn.addEventListener("click", () => printSheet());
// Paint format: capture the active cell's style, apply on the next click.
// #184 — « Peindre le format » copies the WHOLE format of the source cell, not
// a subset of it, so the target ends up identical. This includes the case the
// tool used to refuse: a **plain** source onto a coloured/sized target now
// RESETS the target (empty values clear the declaration) instead of doing
// nothing at all.
const BORDER_STYLES = new Set(["dashed", "dotted", "double", "solid"]);
/** `rgb(…)` / `#rrggbb` → `#rrggbb` (null quand transparent/non-couleur). */
const cssToHex = (css) => {
const s = String(css || "").trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) return `#${s.slice(1).toLowerCase()}`;
const m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/.exec(s);
if (!m) return null;
if (m[4] !== undefined && Number(m[4]) === 0) return null;
const hex = [m[1], m[2], m[3]].map((v) => Number(v).toString(16).padStart(2, "0")).join("");
return `#${hex}`;
};
/** Full format of a cell, as a `cell` style op payload. */
const captureFormat = (td) => {
const st = td.style;
const fontSize = st.fontSize ? Number.parseFloat(st.fontSize) : NaN;
const deco = st.textDecoration || "";
const borderStyle = [st.borderLeftStyle, st.borderTopStyle]
.find((v) => v && v !== "none") || "";
const style = {
bold: st.fontWeight === "600" || st.fontWeight === "bold" || st.fontWeight === "700",
italic: st.fontStyle === "italic",
underline: deco.includes("underline"),
strike: deco.includes("line-through"),
font_color: cssToHex(st.color) || "",
fill_color: cssToHex(st.backgroundColor || st.background) || "",
font_size: Number.isFinite(fontSize)
? Math.min(72, Math.max(6, Math.round(fontSize)))
: 11,
align: ["left", "center", "right", "justify"].includes(st.textAlign)
? st.textAlign
: "left",
wrap: st.whiteSpace === "normal",
// Vertical : 90/180. Diagonal : `rotate(Xdeg)` CSS (horaire) relu en
// angle Excel (anti-horaire, borné à ±90 comme le backend).
rotation: (() => {
if (st.writingMode === "vertical-rl") {
return (st.transform || "").includes("-1") ? 180 : 90;
}
const m = /rotate\(\s*(-?\d+(?:\.\d+)?)\s*deg\s*\)/.exec(st.transform || "");
return m ? Math.max(-90, Math.min(90, Math.round(-Number(m[1])))) : 0;
})(),
number_format: sheetFormatsOf()[cellName(td)] || "General",
};
// `valign` n'est envoyé que si la source en porte un valide (top/middle/
// bottom) : `""` faisait rejeter tout le PUT en 400 côté backend.
if (["top", "middle", "bottom"].includes(st.verticalAlign)) {
style.valign = st.verticalAlign;
}
if (borderStyle) {
style.border = "all";
// openpyxl ne connaît pas `solid` (CSS) : son équivalent est `thin`.
style.border_style = borderStyle === "solid" ? "thin"
: BORDER_STYLES.has(borderStyle) ? borderStyle : "thin";
style.border_color = cssToHex(st.borderLeftColor) || cssToHex(st.borderColor) || "#000000";
} else {
style.border = "none";
}
return style;
};
let paintStyle = null;
let paintOnceHandler = null;
const paintToggle = area.querySelector("#xs-paint");
const cancelPaintKeys = (e) => { if (e.key === "Escape" && paintStyle) stopPaint(); };
const stopPaint = () => {
paintStyle = null;
// #184 — cancelling (Escape, second click on the button) MUST also drop the
// pending "apply on next click" listener: it used to survive and repaint a
// stale style on the next grid click.
if (paintOnceHandler) {
area.removeEventListener("click", paintOnceHandler);
paintOnceHandler = null;
}
document.removeEventListener("keydown", cancelPaintKeys);
if (paintToggle) {
paintToggle.classList.remove("active");
paintToggle.setAttribute("aria-pressed", "false");
}
};
if (paintToggle) paintToggle.addEventListener("click", () => {
if (paintStyle) { stopPaint(); return; }
const td = activeStyleCell();
if (!td) {
showToast(t("xlsx.paint_pick_source"), "info");
return;
}
// A source with no explicit format is legitimate: it means "reset".
paintStyle = captureFormat(td);
paintToggle.classList.add("active");
paintToggle.setAttribute("aria-pressed", "true");
document.addEventListener("keydown", cancelPaintKeys);
showToast(t("xlsx.paint_pick_target"), "info");
paintOnceHandler = (e) => {
if (e.target && e.target.closest && e.target.closest("#xs-paint")) return;
// Toolbar/menu/modal/colour-panel clicks must neither paint nor disarm.
if (e.target && e.target.closest && e.target.closest(
".xlsx-toolbar,.xlsx-minibar,.xlsx-structure-menu,.xlsx-context-menu,"
+ ".xlsx-export-menu,.xlsx-colour-panel,.obsigate-modal-overlay"
)) return;
const captured = paintStyle;
stopPaint();
if (captured) runCellStyle(captured);
};
// Area bubble runs after the grid's own click handlers: the selection is
// already updated when this fires.
area.addEventListener("click", paintOnceHandler);
});
// Zoom: pure client-side scaling, persisted per browser.
const ZOOM_KEY = "obsigate_xlsx_zoom";
const applyZoom = (z) => {
const host = area.querySelector(".xlsx-panels");
if (host) host.style.zoom = z === 100 ? "" : `${z}%`;
const sel = area.querySelector("#xs-zoom");
if (sel) sel.value = String(z);
try { localStorage.setItem(ZOOM_KEY, String(z)); } catch (_) { /* private mode */ }
};
const zoomSel = area.querySelector("#xs-zoom");
if (zoomSel) {
let z = 100;
try { z = Number(localStorage.getItem(ZOOM_KEY)) || 100; } catch (_) { /* ignore */ }
if (![50, 75, 90, 100, 125, 150, 200].includes(z)) z = 100;
applyZoom(z);
zoomSel.addEventListener("change", () => applyZoom(Number(zoomSel.value) || 100));
}
// Quick number formats.
const quickFmt = (id, fmt) => {
const b = area.querySelector(id);
if (b) b.addEventListener("click", () => runCellStyle({ number_format: fmt }));
};
quickFmt("#xs-cur", '#,##0.00 "€"');
quickFmt("#xs-pct", "0.0%");
quickFmt("#xs-num123", "0.00");
const numSel = area.querySelector("#xs-numfmt");
if (numSel) numSel.addEventListener("change", () => {
if (!numSel.value) return;
runCellStyle({ number_format: numSel.value });
numSel.value = "";
});
const shiftDecimals = (delta) => {
const td = activeStyleCell();
if (!td) return;
let fmt = sheetFormatsOf()[cellName(td)] || "General";
if (fmt === "@") return;
if (fmt === "General") {
if (delta < 0) return;
runCellStyle({ number_format: "0.0" });
return;
}
const secs = fmt.split(";");
let head = secs[0];
if (/[yYmMdDhHsS]/.test(head) && !/\.\d/.test(head)) return; // dates: leave alone
const m = head.match(/\.0+/);
if (m) {
let zeros = m[0].slice(1);
zeros = delta > 0 ? `${zeros}0` : zeros.slice(0, -1);
head = zeros ? head.replace(/\.0+/, `.${zeros}`) : head.replace(/\.0+/, "");
} else if (delta > 0) {
head = head.replace(/(%?"[^"]*"?%?)$/, ".0$1");
if (!head.includes(".0")) head += ".0";
} else {
return;
}
secs[0] = head;
runCellStyle({ number_format: secs.join(";") });
};
const decMinus = area.querySelector("#xs-decminus");
if (decMinus) decMinus.addEventListener("click", () => shiftDecimals(-1));
const decPlus = area.querySelector("#xs-decplus");
if (decPlus) decPlus.addEventListener("click", () => shiftDecimals(1));
// Font size + B/I/S/U toggles (state reflected by updateSheetsToolbarStates).
const currentFontSize = () => {
const td = activeStyleCell();
if (!td) return 11;
const px = td.style.fontSize ? Number.parseFloat(td.style.fontSize) : NaN;
return Number.isFinite(px) ? Math.round(px) : 11;
};
const fsMinus = area.querySelector("#xs-fsminus");
if (fsMinus) fsMinus.addEventListener("click", () => {
runCellStyle({ font_size: Math.max(6, currentFontSize() - 1) });
});
const fsPlus = area.querySelector("#xs-fsplus");
if (fsPlus) fsPlus.addEventListener("click", () => {
runCellStyle({ font_size: Math.min(72, currentFontSize() + 1) });
});
const fsSize = area.querySelector("#xs-fssize");
if (fsSize) fsSize.addEventListener("change", () => {
const size = Number(fsSize.value);
if (Number.isFinite(size)) runCellStyle({ font_size: Math.min(72, Math.max(6, Math.round(size))) });
});
const styleToggle = (id, make) => {
const b = area.querySelector(id);
if (b) b.addEventListener("click", () => runCellStyle(make()));
};
styleToggle("#xs-b", () => ({ bold: !(activeStyleCell() && ["600", "bold", "700"].includes(activeStyleCell().style.fontWeight)) }));
styleToggle("#xs-i", () => ({ italic: !(activeStyleCell() && activeStyleCell().style.fontStyle === "italic") }));
styleToggle("#xs-s", () => ({ strike: !hasStrike() }));
styleToggle("#xs-u", () => ({ underline: !hasUnderline() }));
// #183 — the Sheets-like colour panel replaces the native colour input on the
// ribbon. Opening/positioning/dismissal reuse the viewer machinery
// (anchorUnder + trackDismissable); picking a colour applies it and closes.
let dismissColourPanel = null;
const closeColourPanel = () => {
if (dismissColourPanel) { dismissColourPanel(); dismissColourPanel = null; }
area.querySelectorAll("[data-xlsx-colour][aria-expanded='true']")
.forEach((b) => b.setAttribute("aria-expanded", "false"));
};
/** Open the panel for a ribbon colour button (`data-xlsx-colour="font|fill"`). */
const openColourPanel = (btn) => {
if (dismissColourPanel) { closeColourPanel(); return; }
// Ribbon buttons carry `data-xlsx-colour`; the Mise en forme rows carry
// `data-colour-menu`. Both open the same panel.
const kind = (btn.dataset.xlsxColour || btn.dataset.colourMenu) === "fill" ? "fill" : "font";
const native = area.querySelector(
kind === "fill" ? '[data-xlsx-native-for="xs-bgcolor"]' : '[data-xlsx-native-for="xs-fgcolor"]'
);
const swatchId = kind === "fill" ? "xs-bgcolor" : "xs-fgcolor";
const td = activeStyleCell();
// Reflect the colour already on the cell so the check mark starts in the
// right place (a cell with no explicit colour shows none selected).
const current = normHex(kind === "fill"
? (td ? cssToHex(td.style.backgroundColor || td.style.background) : null)
: (td ? cssToHex(td.style.color) : null)) || "";
const { el: panel, sync } = createColourPicker({
kind,
value: current,
custom: current || (native ? normHex(native.value) : "") || "",
onPick: (colour) => {
closeColourPanel();
// Mirror the colour on the button so the user sees the choice stick.
const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`);
if (sw) sw.style.background = colour;
runCellStyle(kind === "fill" ? { fill_color: colour } : { font_color: colour });
},
onReset: () => {
closeColourPanel();
const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`);
if (sw) sw.style.background = "";
runCellStyle(kind === "fill" ? { fill_color: "" } : { font_color: "" });
},
onCustom: () => {
// The native picker stays the only way to reach an arbitrary colour
// (the panel offers 80 + 8 curated ones).
if (native && typeof native.click === "function") native.click();
},
onUnavailable: (label) => showToast(t("xlsx.colour_unavailable", { feature: label }), "info"),
});
panel.dataset.xlsxColourPanel = kind;
const toolbar = btn.closest(".xlsx-toolbar") || area;
toolbar.appendChild(panel);
anchorUnder(panel, toolbar, btn);
safeCreateIcons();
btn.setAttribute("aria-expanded", "true");
dismissColourPanel = trackDismissable(panel, {
toggle: btn,
onClose: () => {
dismissColourPanel = null;
panel.remove();
btn.setAttribute("aria-expanded", "false");
},
});
sync(current);
};
area.querySelectorAll("[data-xlsx-colour]").forEach((btn) => {
btn.addEventListener("click", (e) => { e.preventDefault(); openColourPanel(btn); });
});
// Keep the ribbon swatch in step when the native picker (opened from the
// panel's « + ») yields an arbitrary colour.
area.querySelectorAll("[data-xlsx-native-for]").forEach((input) => {
input.addEventListener("change", (e) => {
const swatchId = input.dataset.xlsxNativeFor;
const sw = area.querySelector(`[data-swatch-for="${swatchId}"]`);
if (sw) sw.style.background = e.target.value;
const kind = swatchId === "xs-bgcolor" ? "fill" : "font";
runCellStyle(kind === "fill" ? { fill_color: e.target.value } : { font_color: e.target.value });
});
});
// Borders / merge / alignments / wrap / rotation.
const bordersBtn = area.querySelector("#xs-borders");
if (bordersBtn) bordersBtn.addEventListener("click", () => openRibbonMenu(bordersBtn, [
{ icon: "grid", label: t("xlsx.border_all"), onPick: () => runCellStyle({ border: "all" }) },
{ icon: "box-select", label: t("xlsx.border_outer"), onPick: () => runCellStyle({ border: "outer" }) },
{ icon: "ban", label: t("xlsx.border_none"), onPick: () => runCellStyle({ border: "none" }) },
]));
const mergeBtn = area.querySelector("#xs-merge");
if (mergeBtn) mergeBtn.addEventListener("click", () => {
const b = selectionBounds();
const panel = visiblePanel();
if (!b || !selection || selection.panel !== panel || (b.r1 === b.r2 && b.c1 === b.c2)) {
showToast(t("xlsx.format_merge_needs_range"), "info");
return;
}
const label = rangeLabel();
const merges = sheets[visibleSheetIndex()]?.merges || [];
const op = merges.includes(label) ? "unmerge" : "merge";
runStyle([{ op, sheet: styleSheetName(), range: label }]);
});
const alignhBtn = area.querySelector("#xs-alignh");
if (alignhBtn) alignhBtn.addEventListener("click", () => openRibbonMenu(alignhBtn, [
{ icon: "align-left", label: t("xlsx.format_align_left"), onPick: () => runCellStyle({ align: "left" }) },
{ icon: "align-center", label: t("xlsx.format_align_center"), onPick: () => runCellStyle({ align: "center" }) },
{ icon: "align-right", label: t("xlsx.format_align_right"), onPick: () => runCellStyle({ align: "right" }) },
{ icon: "align-justify", label: t("xlsx.format_align_justify"), onPick: () => runCellStyle({ align: "justify" }) },
]));
const alignvBtn = area.querySelector("#xs-alignv");
if (alignvBtn) alignvBtn.addEventListener("click", () => openRibbonMenu(alignvBtn, [
{ icon: "align-start-vertical", label: t("xlsx.valign_top"), onPick: () => runCellStyle({ valign: "top" }) },
{ icon: "align-center-vertical", label: t("xlsx.valign_middle"), onPick: () => runCellStyle({ valign: "middle" }) },
{ icon: "align-end-vertical", label: t("xlsx.valign_bottom"), onPick: () => runCellStyle({ valign: "bottom" }) },
]));
const wrapBtn = area.querySelector("#xs-wrap");
if (wrapBtn) wrapBtn.addEventListener("click", () => {
const td = activeStyleCell();
const on = !(td && td.style.whiteSpace === "normal");
runCellStyle({ wrap: on });
});
const rotBtn = area.querySelector("#xs-rot");
if (rotBtn) rotBtn.addEventListener("click", () => openRibbonMenu(rotBtn, [
{ icon: "minus", label: t("xlsx.rotation_0"), onPick: () => runCellStyle({ rotation: 0 }) },
{ icon: "trending-up", label: t("xlsx.rotation_45"), onPick: () => runCellStyle({ rotation: 45 }) },
{ icon: "trending-down", label: t("xlsx.rotation_315"), onPick: () => runCellStyle({ rotation: -45 }) },
{ icon: "arrow-up", label: t("xlsx.rotation_90"), onPick: () => runCellStyle({ rotation: 90 }) },
]));
// Shared flows: insert link, edit comment, insert SUM.
const listSeparator = () => (formulaLocale() === "fr" ? ";" : ",");
const insertLinkFlow = async () => {
const panel = visiblePanel();
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
if (!td) return;
const url = await showPrompt({ title: t("xlsx.link_add"), message: t("xlsx.link_url"), value: "https://" });
if (!url) return;
if (!/^https?:\/\//i.test(url.trim())) {
showToast(t("xlsx.link_invalid"), "error");
return;
}
const label = await showPrompt({ title: t("xlsx.link_add"), message: t("xlsx.link_label"), value: url.trim() });
if (label === null) return;
const clean = (s) => String(s).replace(/"/g, '""');
const sep = listSeparator();
const formula = label
? `=LIEN("${clean(url.trim())}"${sep}"${clean(label)}")`
: `=LIEN("${clean(url.trim())}")`;
setCellValue(td, formula);
refreshPanelFormulas(panel);
};
const editCommentFlow = async () => {
const panel = visiblePanel();
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
if (!td) return;
const sheet = styleSheetName();
const ref = cellName(td);
const meta = sheets[visibleSheetIndex()] || {};
const existing = (meta.comments || {})[ref] || "";
const text = await showPrompt({ title: t("xlsx.comment_add"), message: t("xlsx.comment_prompt"), value: existing });
if (text === null) return;
if (!text.trim()) runStyle([{ op: "comment_clear", sheet, ref }]);
else runStyle([{ op: "comment_set", sheet, ref, text: text.trim() }]);
};
const insertSumFlow = () => {
const panel = visiblePanel();
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
if (!td) return;
const here = parseRef(cellName(td));
if (!here) return;
const sep = listSeparator();
const numAt = (r, c) => {
const cell = findTd(panel, r, c);
if (!cell) return null;
const v = Number.parseFloat(String(cellTextOf(cell)).replace(",", "."));
return Number.isFinite(v) ? v : null;
};
// Prefer the numbers stacked directly above the active cell…
let top = null;
for (let r = here.row - 1; r >= Math.max(1, here.row - 100); r--) {
if (numAt(r, here.col) === null) break;
top = r;
}
const fn = formulaLocale() === "fr" ? "SOMME" : "SUM";
let formula = null;
if (top !== null) {
formula = `=${fn}(${columnName(here.col)}${top}:${columnName(here.col)}${here.row - 1})`;
} else {
const b = selectionBounds();
if (b && selection && selection.panel === panel && (b.r2 > b.r1 || b.c2 > b.c1)) {
formula = `=${fn}(${columnName(b.c1)}${b.r1}:${columnName(b.c2)}${b.r2})`;
}
}
if (!formula) {
showToast(t("xlsx.sum_empty"), "info");
return;
}
setCellValue(td, formula);
refreshPanelFormulas(panel);
};
// Overflow menu: singletons that deserve no permanent button.
const moreBtn = area.querySelector("#xs-more");
if (moreBtn) moreBtn.addEventListener("click", () => openRibbonMenu(moreBtn, [
{ icon: "snowflake", label: t("xlsx.format_freeze"), onPick: () => freezeAtSelection() },
{ icon: "sun", label: t("xlsx.format_unfreeze"), onPick: () => runStyle([{ op: "freeze", sheet: styleSheetName(), cell: "" }]) },
{ sep: true },
{ icon: "arrow-up", label: t("xlsx.sort_asc"), onPick: () => sortFromToolbar("asc") },
{ icon: "arrow-down", label: t("xlsx.sort_desc"), onPick: () => sortFromToolbar("desc") },
{ icon: "filter", label: t("xlsx.find_placeholder"), onPick: () => area.querySelector("#xlsx-find-input")?.focus() },
{ sep: true },
{ icon: "move-horizontal", label: t("xlsx.format_col_width"), onPick: () => promptColumnWidth() },
{ icon: "maximize-2", label: t("xlsx.format_col_fit"), onPick: () => autoFitSelection("col") },
{ icon: "move-vertical", label: t("xlsx.format_row_height"), onPick: () => promptRowHeight() },
{ icon: "maximize-2", label: t("xlsx.format_row_fit"), onPick: () => autoFitSelection("row") },
{ sep: true },
{ icon: "link", label: t("xlsx.link_add"), onPick: () => insertLinkFlow() },
{ icon: "message-square", label: t("xlsx.comment_add"), onPick: () => editCommentFlow() },
]));
const freezeAtSelection = () => {
const ref = selectionRange().split(":")[0];
runStyle([{ op: "freeze", sheet: styleSheetName(), cell: ref }]);
};
const sortFromToolbar = (dir) => {
const panel = visiblePanel();
const b = selection && selection.panel === panel ? selectionBounds() : null;
const ref = activeTd && activeTd.closest(".xlsx-panel") === panel
? parseRef(cellName(activeTd)) : null;
const col = b ? b.c1 : ref ? ref.col : 1;
applySort(panel, col, dir);
};
// ══ Tailles de colonnes / de lignes — manuel et automatique ═══════════
// Standards visés : Excel et Google Sheets.
// · unités : largeur en CARACTÈRES (`Column Width`, openpyxl
// `column_dimensions.width`), hauteur en POINTS (`Row Height`,
// openpyxl `row_dimensions.height`) ;
// · `0` = taille par défaut (le backend efface la mesure explicite —
// l'équivalent de « Default Width » / du bouton Reset de Sheets) ;
// · double-clic sur un bord d'en-tête = ajustement automatique au contenu
// (Excel AutoFit / Sheets « Fit to data »).
// Les facteurs de conversion sont exactement ceux d'applySheetMeta()
// (×7 px par caractère, ×96/72 px par point) : mesure → op → rendu ne
// perd jamais de pixels.
const metaOf = (panel) => sheets[Number(panel.dataset.sheet)] || {};
const pxToChars = (px) => Math.round((px / 7) * 10) / 10;
const pxToPts = (px) => Math.round((px * 72 / 96) * 10) / 10;
/** Lignes du panneau indexées par numéro (le DOM peut sauter des rangs). */
const rowTrIndex = (panel) => {
const map = new Map();
if (!panel) return map;
panel.querySelectorAll("tbody tr").forEach((tr) => {
const n = Number(tr.querySelector("th.xlsx-rownum")?.textContent);
if (n) map.set(n, tr);
});
return map;
};
/** Largeur courante d'une colonne, en caractères (métadonnée ou rendu). */
const currentColWidthChars = (panel, colIdx) => {
const stored = Number(metaOf(panel).colwidths?.[columnName(colIdx)]);
if (Number.isFinite(stored) && stored > 0) return stored;
const th = panel ? panel.querySelectorAll("thead th")[colIdx] : null;
const px = th ? th.getBoundingClientRect().width : 0;
return px ? pxToChars(px) : 8.43; // largeur par défaut d'Excel
};
/** Hauteur courante d'une ligne, en points (métadonnée ou rendu). */
const currentRowHeightPts = (panel, rowNum) => {
const stored = Number(metaOf(panel).rowheights?.[String(rowNum)]);
if (Number.isFinite(stored) && stored > 0) return stored;
const tr = rowTrIndex(panel).get(rowNum);
const px = tr ? tr.getBoundingClientRect().height : 0;
return px ? pxToPts(px) : 15; // hauteur par défaut d'Excel (15 pt)
};
let _fitCtx = null;
const textWidthPx = (text, cs) => {
if (!_fitCtx) _fitCtx = document.createElement("canvas").getContext("2d");
_fitCtx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
let w = 0;
for (const line of String(text).split("\n")) w = Math.max(w, _fitCtx.measureText(line).width);
return w;
};
/** Excel AutoFit / Sheets « Fit to data » : la colonne prend la largeur de
* sa ligne de contenu la plus large (padding et bordure compris). */
const autoFitColumns = (panel, cols) => {
if (!panel || !cols.length) return;
const wanted = new Set(cols);
const fits = new Map();
panel.querySelectorAll("tbody tr").forEach((tr) => {
if (tr.style.display === "none") return; // ligne masquée par un filtre
tr.querySelectorAll("td[data-cell]").forEach((td) => {
if (td.style.display === "none") return; // cellule couverte par une fusion
const ref = parseRef(td.dataset.cell);
if (!ref || !wanted.has(ref.col)) return;
const text = td.querySelector(".xlsx-cached") ? cellTextOf(td) : td.textContent;
if (!text) return;
const cs = getComputedStyle(td);
const px = Math.ceil(textWidthPx(text, cs))
+ (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0)
+ (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
if (px > (fits.get(ref.col) || 0)) fits.set(ref.col, px);
});
});
const ops = [];
cols.forEach((c) => {
const px = fits.get(c);
if (!px) return; // colonne vide : on n'impose rien
const width = Math.min(255, Math.max(3, pxToChars(px)));
const stored = Number(metaOf(panel).colwidths?.[columnName(c)]);
if (Number.isFinite(stored) && Math.abs(stored - width) < 0.1) return;
ops.push({ op: "col_width", sheet: styleSheetName(), col: columnName(c), width });
});
if (!ops.length) { showToast(t("xlsx.fit_nothing"), "info"); return; }
runStyle(ops);
};
/** Excel AutoFit Row Height / Sheets « Fit to data » : la ligne prend la
* hauteur naturelle de son contenu (retours à la ligne et renvoi compris). */
const autoFitRows = (panel, rows) => {
if (!panel || !rows.length) return;
const index = rowTrIndex(panel);
const ops = [];
rows.forEach((rowNum) => {
const tr = index.get(rowNum);
if (!tr || tr.style.display === "none") return;
const prev = tr.style.height;
tr.style.height = "auto";
let need = 0;
tr.querySelectorAll("td[data-cell]").forEach((td) => {
if (td.style.display === "none") return;
const cs = getComputedStyle(td);
need = Math.max(need, (td.scrollHeight || 0)
+ (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0));
});
tr.style.height = prev;
if (!need) return;
const height = Math.min(409, Math.max(9, pxToPts(need)));
const stored = Number(metaOf(panel).rowheights?.[String(rowNum)]);
if (Number.isFinite(stored) && Math.abs(stored - height) < 0.1) return;
ops.push({ op: "row_height", sheet: styleSheetName(), row: rowNum, height });
});
if (!ops.length) { showToast(t("xlsx.fit_nothing"), "info"); return; }
runStyle(ops);
};
/** Ajustement automatique sur la sélection courante (ou la cellule active) :
* c'est l'entrée de menu, le geste et le clic droit d'en-tête qui l'appellent. */
const autoFitSelection = (axis) => {
const panel = visiblePanel();
if (!panel) return;
const b = selection && selection.panel === panel ? selectionBounds() : null;
const ref = parseRef(selectionRange().split(":")[0]) || {};
if (axis === "col") {
const cols = [];
if (b) { for (let c = b.c1; c <= b.c2; c++) cols.push(c); }
else cols.push(ref.col || 1);
autoFitColumns(panel, cols);
} else {
const rows = [];
if (b) { for (let r = b.r1; r <= b.r2; r++) rows.push(r); }
else rows.push(ref.row || 1);
autoFitRows(panel, rows);
}
};
const promptColumnWidth = async () => {
const panel = visiblePanel();
const b = selection && selection.panel === panel ? selectionBounds() : null;
const cols = [];
if (b) { for (let c = b.c1; c <= b.c2; c++) cols.push(c); }
else cols.push((parseRef(selectionRange().split(":")[0]) || { col: 1 }).col);
const letters = cols.map(columnName);
const answer = await showPrompt({
title: t("xlsx.format_col_width"),
message: t("xlsx.format_col_prompt", { col: letters.join(", ") }),
// Comme Excel / Sheets : la valeur courante est pré-remplie.
value: String(currentColWidthChars(panel, cols[0])),
});
if (answer === null || answer === undefined || String(answer).trim() === "") return;
const width = Number(String(answer).replace(",", "."));
if (!Number.isFinite(width) || width < 0 || width > 255) return;
runStyle(letters.map((col) => ({ op: "col_width", sheet: styleSheetName(), col, width })));
};
const promptRowHeight = async () => {
const panel = visiblePanel();
const b = selection && selection.panel === panel ? selectionBounds() : null;
const rows = [];
if (b) { for (let r = b.r1; r <= b.r2; r++) rows.push(r); }
else rows.push((parseRef(selectionRange().split(":")[0]) || { row: 1 }).row);
const answer = await showPrompt({
title: t("xlsx.format_row_height"),
message: t("xlsx.format_row_prompt", { row: rows.join(", ") }),
// Comme Excel / Sheets : la valeur courante est pré-remplie.
value: String(currentRowHeightPts(panel, rows[0])),
});
if (answer === null || answer === undefined || String(answer).trim() === "") return;
const height = Number(String(answer).replace(",", "."));
if (!Number.isFinite(height) || height < 0 || height > 409) return;
runStyle(rows.map((row) => ({ op: "row_height", sheet: styleSheetName(), row, height })));
};
// Floating mini-bar on multi-cell selections: link, comment, clear filter, SUM.
const minibar = document.createElement("div");
minibar.className = "xlsx-minibar";
minibar.id = "xlsx-minibar";
minibar.hidden = true;
const miniIcon = (name) => ``;
minibar.innerHTML =
`` +
`` +
`` +
``;
area.appendChild(minibar);
safeCreateIcons();
minibar.addEventListener("click", (e) => {
const btn = e.target && e.target.closest ? e.target.closest("[data-mini]") : null;
if (!btn) return;
const kind = btn.dataset.mini;
if (kind === "link") insertLinkFlow();
else if (kind === "comment") editCommentFlow();
else if (kind === "clear") sortResetBtn.click();
else if (kind === "sum") insertSumFlow();
});
const updateMinibar = () => {
const b = selection && selection.panel === visiblePanel() ? selectionBounds() : null;
if (!editable || !b || (b.r1 === b.r2 && b.c1 === b.c2)) {
minibar.hidden = true;
return;
}
const panel = visiblePanel();
const anchor = findTd(panel, b.r2, b.c2) || findTd(panel, b.r1, b.c1);
if (!anchor) { minibar.hidden = true; return; }
const r = anchor.getBoundingClientRect();
const vw = window.innerWidth || 1024;
const vh = window.innerHeight || 768;
minibar.hidden = false;
const mw = minibar.offsetWidth || 160;
const mh = minibar.offsetHeight || 36;
minibar.style.left = `${Math.max(4, Math.min(r.right - mw, vw - mw - 4))}px`;
minibar.style.top = `${Math.max(4, Math.min(r.bottom + 6, vh - mh - 4))}px`;
};
const hideMinibar = () => { minibar.hidden = true; };
panelEls.forEach((panel) => {
const wrap = panel.querySelector(".csv-table-wrapper");
if (wrap) wrap.addEventListener("scroll", hideMinibar, { passive: true });
});
// ══ #155 — Excel-like selection & context menu ═════════════════════════
const viewerEl = area.querySelector(".xlsx-viewer");
// ══ #156-A5 — presse-papiers de plage (copier / couper / coller un bloc) ══
// The grid keeps its own TSV clipboard so a copy → paste round-trip works
// even when the system clipboard is unavailable; the async Clipboard API is
// a best-effort mirror and the fallback source for a block that did not come
// from this grid (e.g. one copied in Excel). Values are always inserted as
// plain text — never HTML (risk R4).
let rangeClipboard = null; // { panel, r1, r2, c1, c2, rows, cut }
let pendingCut = null; // cut source range, cleared by the next paste
/** Displayed text of a cell (the cached-value shadow is a tooltip, not data). */
const cellTextOf = (td) => {
if (!td) return "";
const clone = td.cloneNode(true);
clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
return clone.textContent;
};
const toTsv = (rows) => rows.map((line) => line.join("\t")).join("\n");
/** The selected range as a matrix of displayed values. */
const selectionMatrix = () => {
const b = selectionBounds();
if (!b || !selection) return null;
const rows = [];
for (let r = b.r1; r <= b.r2; r++) {
const line = [];
for (let c = b.c1; c <= b.c2; c++) line.push(cellTextOf(findTd(selection.panel, r, c)));
rows.push(line);
}
return rows;
};
/** Best-effort system-clipboard write (silently ignored when unavailable). */
const writeClipboard = async (text) => {
try {
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (_) { /* restricted clipboard: the internal one still works */ }
return false;
};
/** The selected range as a matrix of RAW values (formulas travel, Excel-like). */
const selectionRawMatrix = () => {
const b = selectionBounds();
if (!b || !selection) return null;
const rows = [];
for (let r = b.r1; r <= b.r2; r++) {
const line = [];
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(selection.panel, r, c);
line.push(td ? cellRaw(td) : "");
}
rows.push(line);
}
return rows;
};
/** Copy the selected range. Returns false when there is nothing to copy. */
const copySelection = () => {
const b = selectionBounds();
// Raw content (not display values): a copied formula is pasted as a
// formula and recomputed at its target, like a spreadsheet.
const rows = selectionRawMatrix();
if (!b || !rows || !selection) return false;
rangeClipboard = { panel: selection.panel, ...b, rows, cut: false };
pendingCut = null;
writeClipboard(toTsv(rows));
return true;
};
/** Cut the selected range: the source is cleared by the next paste (Excel). */
const cutSelection = () => {
if (!copySelection()) return false;
rangeClipboard.cut = true;
pendingCut = { panel: rangeClipboard.panel, ...selectionBounds() };
return true;
};
/** True when the user has a live text selection inside *td* (edit mode). */
const hasCellTextSelection = (td) => {
const sel = window.getSelection ? window.getSelection() : null;
return Boolean(sel && !sel.isCollapsed && sel.rangeCount && td && td.contains(sel.anchorNode));
};
/** Put the caret at the end of a cell's text (F2 / navigation → edit). */
const placeCaretAtEnd = (td) => {
const sel = window.getSelection ? window.getSelection() : null;
if (!sel || !document.createRange) return;
const range = document.createRange();
range.selectNodeContents(td);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
};
// #181-A1 — the two cell states. A SELECTED cell shows the outline and no
// caret; an EDITING cell shows the caret and accepts typing. `enterEditing`
// optionally empties the cell first (first keystroke on a selected cell, like
// Sheets/Excel); `exitEditing` is idempotent and safe on the blur path.
const enterEditing = (td, { replace = false } = {}) => {
if (!td) return;
if (replace) {
// Drop the value and the helper spans (cached shadow, comment dot,
// formula link) so the keystroke starts from an empty cell.
td.querySelectorAll(".xlsx-cached,.xlsx-comment-dot").forEach((el) => el.remove());
td.textContent = "";
delete td.dataset.formula;
delete td.dataset.display;
td.classList.add("xlsx-dirty");
}
td.dataset.editing = "1";
td.classList.add("xlsx-editing");
// Focus first, then let the caller place the caret. `editingRequested`
// survives the focus event (the cell handler would clear the state).
editingRequested = true;
if (document.activeElement !== td) td.focus();
editingRequested = false;
syncFormulaBar(td);
refreshSaveState();
};
const exitEditing = (td) => {
if (!td) return;
td.dataset.editing = "";
td.classList.remove("xlsx-editing");
};
const startEditing = (td) => {
enterEditing(td);
placeCaretAtEnd(td);
};
/**
* Put the caret where the user double-clicked, instead of at the end: a
* Range built from the click offset selects the word under the pointer, which
* is the Sheets/Excel behaviour (double-click edits in place).
*/
const placeCaretAtPoint = (td, clientX, clientY) => {
const sel = window.getSelection ? window.getSelection() : null;
if (!sel || typeof document.caretRangeFromPoint !== "function") {
placeCaretAtEnd(td);
return;
}
let range = null;
try {
range = document.caretRangeFromPoint(clientX, clientY);
} catch (_) {
range = null;
}
if (!range || !td.contains(range.startContainer)) {
placeCaretAtEnd(td);
return;
}
sel.removeAllRanges();
sel.addRange(range);
};
/** Insert plain text at the caret inside a cell (no HTML, no execCommand). */
const insertTextAtCaret = (td, text) => {
const sel = window.getSelection ? window.getSelection() : null;
if (sel && sel.rangeCount && td.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
td.appendChild(document.createTextNode(text));
}
enterEditing(td);
td.classList.add("xlsx-dirty");
syncFormulaBar(td);
refreshSaveState();
};
/** Clear a range through the history stack (undoable), skipping *skip*. */
const clearRange = (panel, b, skip = null) => {
if (!panel || !b) return 0;
let cleared = 0;
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
if (skip && r >= skip.r1 && r <= skip.r2 && c >= skip.c1 && c <= skip.c2) continue;
const td = findTd(panel, r, c);
if (!td) continue;
const before = cellTextOf(td);
if (before === "") continue;
const beforeFormula = td.dataset.formula ?? null;
td.textContent = "";
delete td.dataset.formula;
delete td.dataset.display;
td.querySelectorAll(".xlsx-comment-dot").forEach((el) => el.remove());
td.classList.toggle("xlsx-dirty", (td.dataset.orig ?? "") !== "");
recordEdit(td, before, "", beforeFormula, null);
resignPending(td);
cleared++;
}
}
refreshSaveState();
return cleared;
};
/** Split pasted text into a matrix (TSV from a spreadsheet, or plain lines). */
const parseMatrix = (text) => {
const raw = String(text ?? "").replace(/\r\n?/g, "\n");
if (!raw) return [];
const lines = raw.split("\n");
// A spreadsheet copy ends with a newline: drop the empty row it produces.
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
return lines.map((line) => line.split("\t"));
};
/** Plain-text payload of a clipboard event ("" when unavailable). */
const clipTextOf = (e) => {
const data = (e && (e.clipboardData || window.clipboardData)) || null;
if (!data || typeof data.getData !== "function") return "";
try {
return data.getData("text/plain") || data.getData("text") || "";
} catch (_) {
return "";
}
};
// A range operation that rewrites the cell being edited must not produce a
// second history entry when that cell later loses focus (commitEdit).
const resignPending = (td) => {
if (pendingEdit && pendingEdit.td === td) pendingEdit.before = td.textContent;
};
/** Write one value into a cell through the history stack. */
const setCellValue = (td, value) => {
const before = cellTextOf(td);
if (before === value) return false;
const beforeFormula = td.dataset.formula ?? null;
td.textContent = value;
delete td.dataset.formula;
delete td.dataset.display;
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
recordEdit(td, before, value, beforeFormula, isFormulaText(value) ? value.trim() : null);
resignPending(td);
return true;
};
/** Paste a matrix starting at *startTd*; keeps the pasted block selected. */
const fillFrom = (startTd, matrix) => {
const panel = startTd && startTd.closest(".xlsx-panel");
const start = startTd && parseRef(cellName(startTd));
if (!panel || !start || !matrix.length) return null;
const rows = matrix.length;
const cols = matrix.reduce((n, line) => Math.max(n, line.length), 0);
let missing = 0;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const td = findTd(panel, start.row + i, start.col + j);
if (!td) { missing++; continue; }
setCellValue(td, matrix[i][j] ?? "");
}
}
const target = {
r1: start.row, r2: start.row + rows - 1,
c1: start.col, c2: start.col + cols - 1,
};
// Excel keeps the pasted block selected: select the whole target range
// (cells outside the rendered window are simply not painted) and tell the
// user when part of the block could not be written.
selection = {
panel,
anchor: { row: target.r1, col: target.c1 },
focus: { row: target.r2, col: target.c2 },
};
applySelection();
activeTd = startTd;
formulaInput.disabled = false;
formulaInput.value = barTextOf(startTd);
refreshSaveState();
refreshPanelFormulas(panel);
if (missing) showToast(t("xlsx.paste_out_of_grid"), "info");
return target;
};
/** Paste one value: replace the cell, or insert at the caret mid-edit. */
const pasteSingleValue = (td, value) => {
if (hasCellTextSelection(td)) { insertTextAtCaret(td, value); return; }
if (setCellValue(td, value)) {
syncFormulaBar(td);
refreshSaveState();
}
};
/** Common paste path: 1×1 → single cell, otherwise fill the block. */
const applyPaste = (text, targetTd) => {
const td = targetTd || (activeTd && area.contains(activeTd) ? activeTd : null);
const matrix = parseMatrix(text);
if (!td || !matrix.length) return false;
if (matrix.length === 1 && matrix[0].length === 1) {
pasteSingleValue(td, matrix[0][0]);
} else {
const target = fillFrom(td, matrix);
// A cut is committed once its source is cleared; cells inside the target
// range are kept so cut → paste in place never erases the paste.
if (pendingCut && area.contains(pendingCut.panel)) clearRange(pendingCut.panel, pendingCut, target);
}
pendingCut = null;
if (rangeClipboard) rangeClipboard.cut = false;
return true;
};
/** Context-menu “Coller”: internal block first, system clipboard next. */
const pasteFromClipboard = async () => {
if (rangeClipboard && applyPaste(toTsv(rangeClipboard.rows))) return;
let text = "";
try {
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.readText) {
text = await navigator.clipboard.readText();
}
} catch (_) { /* permission denied / unavailable */ }
if (!text || !applyPaste(text)) showToast(t("xlsx.paste_empty"), "info");
};
// Ctrl+C / Ctrl+X are handled through the clipboard events: they also cover
// the browser's own Edit menu, and a live text selection inside the edited
// cell keeps its native behaviour.
viewerEl.addEventListener("copy", (e) => {
const td = e.target && e.target.closest ? e.target.closest("td[data-cell]") : null;
if (td && hasCellTextSelection(td)) return; // native in-cell copy
const inGrid = e.target && e.target.closest && e.target.closest(".xlsx-panel");
if (!inGrid || !copySelection()) return;
if (e.clipboardData && e.clipboardData.setData) e.clipboardData.setData("text/plain", toTsv(rangeClipboard.rows));
e.preventDefault();
});
viewerEl.addEventListener("cut", (e) => {
const td = e.target && e.target.closest ? e.target.closest("td[data-cell]") : null;
if (td && hasCellTextSelection(td)) return; // native in-cell cut
const inGrid = e.target && e.target.closest && e.target.closest(".xlsx-panel");
if (!inGrid || !cutSelection()) return;
if (e.clipboardData && e.clipboardData.setData) e.clipboardData.setData("text/plain", toTsv(rangeClipboard.rows));
e.preventDefault();
});
// ══ #156-A6 — clavier complet & #156-A7 — zone Nom ══════════════════
// A cell is in “navigation” mode until the user types (or presses F2); that
// single rule is what lets Delete clear the selection instead of deleting a
// character, and Home/End reach the row instead of the caret.
const isTypingInCell = (td) => Boolean(td && td.dataset.editing === "1");
const activeOrFirst = (panel) => {
if (panel && activeTd && panel.contains(activeTd)) return activeTd;
return panel ? panel.querySelector("td[data-cell]") : null;
};
const goTo = (td) => {
if (!td) return;
setActiveCell(td);
td.focus();
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
};
/** Move the `xlsx-active` marker without touching the range selection. */
const markActive = (td) => {
const prev = area.querySelector("td.xlsx-active");
if (prev && prev !== td) prev.classList.remove("xlsx-active");
if (td) td.classList.add("xlsx-active");
};
const gridGeom = (panel) => ({
rows: panel.querySelectorAll("th.xlsx-rownum").length,
cols: panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length,
});
/** Ctrl+flèche — last cell of a data run (or the first non-empty after a gap). */
const jumpActive = (panel, key, extend) => {
const start = activeOrFirst(panel);
const ref = start && parseRef(cellName(start));
const step = {
ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0],
}[key];
if (!ref || !step) return;
const [dr, dc] = step;
const at = (i) => findTd(panel, ref.row + dr * i, ref.col + dc * i);
const blank = (td) => !td || cellTextOf(td).trim() === "";
let last = 0;
while (at(last + 1)) last++;
if (!last) return;
let i = 1;
if (blank(at(1))) {
while (i < last && blank(at(i))) i++; // skip the gap → next data
} else {
while (i < last && !blank(at(i + 1))) i++; // end of the data run
}
const target = at(i);
if (!target || target === start) return;
if (extend && selection && selection.panel === panel) {
suppressSelectionReset = true;
target.focus();
suppressSelectionReset = false;
selection.focus = parseRef(cellName(target)) || selection.focus;
applySelection();
activeTd = target;
formulaInput.value = barTextOf(target);
return;
}
goTo(target);
};
const selectAllCells = (panel) => {
const { rows, cols } = gridGeom(panel);
if (!rows || !cols) return;
selection = { panel, anchor: { row: 1, col: 1 }, focus: { row: rows, col: cols } };
applySelection();
};
const edgeOfRow = (panel, which) => {
const cur = activeOrFirst(panel);
const ref = cur && parseRef(cellName(cur));
const { cols } = gridGeom(panel);
if (!ref || !cols) return;
goTo(findTd(panel, ref.row, which === "first" ? 1 : cols));
};
const sheetCorner = (panel, key) => {
if (key === "Home") { goTo(panel.querySelector("td[data-cell]")); return; }
const { rows, cols } = gridGeom(panel);
if (rows && cols) goTo(findTd(panel, rows, cols));
};
const pageMove = (panel, dir) => {
const cur = activeOrFirst(panel);
const ref = cur && parseRef(cellName(cur));
const { rows } = gridGeom(panel);
if (!ref || !rows) return;
// One screenful: visible height ÷ row height, with a fallback when the
// layout is not measured (JSDOM reports 0 for every box).
const wrapper = panel.querySelector(".csv-table-wrapper");
const rowEl = panel.querySelector("tbody tr");
const h = (wrapper && wrapper.clientHeight) || 0;
const rh = (rowEl && rowEl.getBoundingClientRect().height) || 0;
const step = h && rh ? Math.max(1, Math.floor(h / rh) - 1) : 10;
const row = Math.min(rows, Math.max(1, ref.row + dir * step));
goTo(findTd(panel, row, ref.col));
};
/** #156-A7 — jump to a typed reference ("B12", "A1:B3", "Feuille!B12"). */
const goToRef = (raw) => {
const value = String(raw || "").trim().replace(/\$/g, "");
if (!value) return false;
const sheetName = sheetOfRef(value);
const body = value.includes("!") ? value.split("!").pop() : value;
const parts = body.toUpperCase().split(":");
const from = parseRef(parts[0]);
const to = parts[1] ? parseRef(parts[1]) : from;
if (!from || !to) return false;
let panel = visiblePanel();
if (sheetName) {
const idx = sheets.findIndex((s) => s.name === sheetName);
const tab = idx >= 0 ? area.querySelector(`.xlsx-tab[data-sheet="${idx}"]`) : null;
if (!tab) return false;
tab.click();
panel = panelEls[idx];
}
if (!panel) return false;
const anchorTd = findTd(panel, from.row, from.col);
if (!anchorTd) { showToast(t("xlsx.range_not_visible"), "info"); return false; }
const focusTd = findTd(panel, to.row, to.col) || anchorTd;
selection = {
panel,
anchor: { row: from.row, col: from.col },
focus: parseRef(cellName(focusTd)) || from,
};
activeTd = anchorTd;
markActive(anchorTd);
formulaInput.disabled = false;
formulaInput.value = barTextOf(anchorTd);
// Focusing the anchor must not collapse a typed range back to one cell:
// the focus handler skips the selection reset under this flag.
suppressSelectionReset = true;
anchorTd.focus();
suppressSelectionReset = false;
selection = {
panel,
anchor: { row: from.row, col: from.col },
focus: parseRef(cellName(focusTd)) || from,
};
applySelection();
if (typeof anchorTd.scrollIntoView === "function") anchorTd.scrollIntoView({ block: "center" });
return true;
};
activeCellEl.addEventListener("focus", () => { activeCellEl.select(); });
activeCellEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const okRef = goToRef(activeCellEl.value);
if (!okRef) showToast(t("xlsx.name_box_invalid", { ref: activeCellEl.value }), "error");
activeCellEl.value = rangeLabel() || "";
if (activeTd) activeTd.focus();
} else if (e.key === "Escape") {
e.preventDefault();
activeCellEl.value = rangeLabel() || "";
if (activeTd) activeTd.focus();
}
});
// #156-A7 — function-name suggestions for the formula bar. The names are
// localized through the catalog (Excel/Sheets each ship their own spelling).
const fnList = area.querySelector("#xlsx-function-list");
if (fnList) {
String(t("xlsx.fn_suggestions") || "")
.split(";")
.map((s) => s.trim())
.filter(Boolean)
.forEach((name) => {
const opt = document.createElement("option");
opt.value = name;
fnList.appendChild(opt);
});
}
// #156-A6 — one grid-level keyboard handler, in priority order: save is
// global (the formula bar keeps it too), then undo/redo, then navigation.
// The formula/find inputs keep their native editing keys.
area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => {
const key = e.key;
const lower = key.length === 1 ? key.toLowerCase() : key;
const mod = e.ctrlKey || e.metaKey;
const inBar = e.target === formulaInput || e.target === activeCellEl;
const panel = (selection && selection.panel) || (e.target.closest && e.target.closest(".xlsx-panel")) || visiblePanel();
if (!panel) return;
if (mod && lower === "s") {
e.preventDefault();
if (!saveBtn.disabled) saveBtn.click();
return;
}
if (inBar) return; // formula/find inputs: native editing keys
if (mod && lower === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
if (mod && lower === "y") { e.preventDefault(); redo(); return; }
if (readOnly) return; // navigation below would activate an uneditable grid
if (mod && lower === "a") { e.preventDefault(); selectAllCells(panel); return; }
if (mod && key.startsWith("Arrow")) { e.preventDefault(); jumpActive(panel, key, e.shiftKey); return; }
if (mod && (key === "Home" || key === "End")) { e.preventDefault(); sheetCorner(panel, key); return; }
if (key === "Delete" && !isTypingInCell(activeTd)) {
const b = selectionBounds();
if (!selection || !b) return;
e.preventDefault();
clearRange(selection.panel, b);
return;
}
if (!mod && (key === "Home" || key === "End") && !isTypingInCell(activeTd)) {
e.preventDefault();
edgeOfRow(panel, key === "Home" ? "first" : "last");
return;
}
if (!mod && (key === "PageUp" || key === "PageDown")) {
e.preventDefault();
pageMove(panel, key === "PageDown" ? 1 : -1);
}
});
const contextTargetFrom = (target) => {
if (!target || !target.closest) return null;
const panel = target.closest(".xlsx-panel");
if (!panel) return null;
const rownum = target.closest("th.xlsx-rownum");
if (rownum) return { panel, kind: "row", row: Number(rownum.textContent) };
const header = target.closest(".xlsx-table thead th:not(.xlsx-corner)");
if (header) return { panel, kind: "col", col: [...header.parentElement.children].indexOf(header) };
const td = target.closest("td[data-cell]");
if (td) return { panel, kind: "cell", td };
return null;
};
const selectForTarget = (target) => {
if (target.kind === "row") selectRow(target.panel, target.row);
else if (target.kind === "col") selectColumn(target.panel, target.col);
else if (target.kind === "cell"
&& (!selection || selection.panel !== target.panel || !inSelection(target.td))) {
setActiveCell(target.td);
}
};
// One structure write (insert/delete) with the usual lossy/error handling;
// destructive actions ask for confirmation first.
const structureAction = (action, confirmMsg) => {
const run = async () => {
try { await putStructure([action]); } catch (err) { structureError(err, [action]); }
};
if (confirmMsg) {
showConfirm({
title: t("xlsx.structure_title"),
message: confirmMsg,
confirmLabel: t("common.confirm"),
danger: true,
}).then((ok) => { if (ok) run(); });
} else {
run();
}
};
// #156-A5/A6 — clearing goes through clearRange() so "Effacer le contenu",
// Suppr and the cut source all share the same undoable path.
const clearContents = (panel, b) => clearRange(panel, b);
// ══ #185 — barre de menus façon Google Sheets ═══════════════════════════
// The ten menus are BUILT by frontend/js/xlsx/menus.js (pure data); this block
// only wires them: open/close with the #179 dismissal contract, accordion
// sections (Nombre, Thème, Zoom, …) and one registry mapping each entry's
// symbolic action to an existing flow. Entries ObsiGate does not implement
// say so out loud instead of doing nothing.
const menuBar = area.querySelector(".xlsx-menubar");
if (menuBar) {
const activeRefOf = () => parseRef(selectionRange().split(":")[0]) || { row: 1, col: 1 };
const sheetNameOf = () => sheets[visibleSheetIndex()]?.name || "";
const fileNameOf = () => String(data.path || "").split("/").pop();
/** A theme token as `#rrggbb` for a style payload (never hardcoded). */
const themeColour = (token, fallback) => {
let raw = "";
try {
raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
} catch (_) { /* no layout engine */ }
return normHex(raw) || fallback;
};
// ── presentation toggles (Affichage ▸ Afficher, Outils ▸ Accessibilité) ──
const gridBtn = area.querySelector("#xs-grid");
const toggleGrid = () => {
const hidden = viewerEl.classList.toggle("xlsx-no-grid");
const on = !hidden;
if (gridBtn) {
gridBtn.setAttribute("aria-pressed", String(on));
gridBtn.classList.toggle("active", on);
}
return on;
};
if (gridBtn) gridBtn.addEventListener("click", toggleGrid);
const toggleFullscreen = () => {
const root = document.documentElement;
if (document.fullscreenElement) {
if (document.exitFullscreen) document.exitFullscreen();
return;
}
const ask = root.requestFullscreen || root.webkitRequestFullscreen;
if (!ask) { showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info"); return; }
try {
const p = ask.call(root);
if (p && typeof p.catch === "function") {
p.catch(() => showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info"));
}
} catch (_) {
showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_fullscreen") }), "info");
}
};
// ── Modifier ▸ Collage spécial ▸ valeurs uniquement ─────────────────────
const valuesOnlyMatrix = (rows, sheetName) => {
const locale = formulaLocale();
const scope = createScope({ readRaw: readRawCell, sheet: sheetName, locale });
return rows.map((line) => line.map((raw) => {
const text = String(raw === null || raw === undefined ? "" : raw);
if (!isFormulaText(text)) return text;
try {
return String(displayValue(evaluate(parseFormula(text), scope), locale));
} catch (_) {
return text;
}
}));
};
const pasteValuesOnly = async () => {
const td = activeTd && area.contains(activeTd) ? activeTd : null;
if (!td) return;
const name = sheetNameOf();
if (rangeClipboard && rangeClipboard.rows && rangeClipboard.rows.length) {
const tsv = toTsv(valuesOnlyMatrix(rangeClipboard.rows, name));
if (applyPaste(tsv, td)) return;
}
let text = "";
try {
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.readText) {
text = await navigator.clipboard.readText();
}
} catch (_) { /* permission denied */ }
const matrix = text ? parseMatrix(text) : [];
if (!matrix.length || !applyPaste(toTsv(valuesOnlyMatrix(matrix, name)), td)) {
showToast(t("xlsx.paste_empty"), "info");
}
};
// ── Insertion ▸ rows / columns / cells ─────────────────────────────────
const insertRows = (where) => {
const panel = visiblePanel();
const name = sheetNameOf();
const b = selectionBounds();
if (b && selection && selection.panel === panel) {
structureAction({
op: "row_insert", sheet: name, count: b.r2 - b.r1 + 1,
at: where < 0 ? b.r1 : b.r2 + 1,
});
return;
}
const ref = activeRefOf();
structureAction({ op: "row_insert", sheet: name, at: where < 0 ? ref.row : ref.row + 1 });
};
const insertCols = (where) => {
const panel = visiblePanel();
const name = sheetNameOf();
const b = selectionBounds();
if (b && selection && selection.panel === panel) {
structureAction({
op: "col_insert", sheet: name, count: b.c2 - b.c1 + 1,
at: where < 0 ? b.c1 : b.c2 + 1,
});
return;
}
const ref = activeRefOf();
structureAction({ op: "col_insert", sheet: name, at: where < 0 ? ref.col : ref.col + 1 });
};
// ── Insertion ▸ Fonction : insert the call, caret inside the parens ─────
const insertFunctionFlow = async () => {
const panel = visiblePanel();
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
if (!td) return;
const answer = await showPrompt({
title: t("xlsx.mi_function"),
message: t("xlsx.mi_function_hint"),
value: formulaLocale() === "fr" ? "SOMME" : "SUM",
});
if (answer === null) return;
const name = String(answer).trim().toUpperCase();
if (!name) return;
if (!/^[A-Z][A-Z0-9._]*$/.test(name)) {
showToast(t("xlsx.name_box_invalid", { ref: name }), "info");
return;
}
const text = `=${name}()`;
setCellValue(td, text);
enterEditing(td);
syncFormulaBar(td);
const sel = window.getSelection ? window.getSelection() : null;
if (sel && document.createRange && td.firstChild) {
const range = document.createRange();
range.setStart(td.firstChild, Math.max(0, text.length - 1));
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
};
// ── Format ▸ Couleurs alternées (batchées : 50 ops par requête) ────────
const applyBandedRows = async () => {
const panel = visiblePanel();
const b = selectionBounds();
if (!b || !selection || selection.panel !== panel || b.r2 === b.r1) {
showToast(t("xlsx.format_merge_needs_range"), "info");
return;
}
const name = sheetNameOf();
const band = themeColour("--accent-bg", t("xlsx.mi_banded_default"));
const ops = [];
for (let r = b.r1; r <= b.r2; r++) {
const shaded = (r - b.r1) % 2 === 0;
ops.push({
op: "cell", sheet: name,
range: `${columnName(b.c1)}${r}:${columnName(b.c2)}${r}`,
style: { fill_color: shaded ? band : "" },
});
}
for (let i = 0; i < ops.length; i += 50) {
const chunk = ops.slice(i, i + 50);
try {
await runStyle(chunk);
} catch (_) {
showToast(t("xlsx.mi_banded_done", { n: Math.min(50, ops.length - i) }), "info");
return;
}
}
showToast(t("xlsx.mi_banded_done", { n: b.r2 - b.r1 + 1 }), "success");
};
// ── Format ▸ Thème : the theme list is built on demand ─────────────────
const fillThemeSection = async (row) => {
const sub = row.nextElementSibling;
if (!sub) return;
if (sub.dataset.filled !== "1") {
let entries = [];
try {
const mod = await import("./themes.js");
entries = typeof mod.listThemes === "function" ? mod.listThemes() : [];
} catch (_) { entries = []; }
sub.innerHTML = entries.length
? entries.map((theme) => (
``
)).join("")
: `
${escapeHtml(t("xlsx.menu_unavailable_hint"))}
`;
sub.dataset.filled = "1";
}
row.setAttribute("aria-expanded", "true");
sub.hidden = false;
};
// ── Données ▸ nettoyage / découpe ──────────────────────────────────────
const requireSelection = () => {
const panel = visiblePanel();
const b = selectionBounds();
if (!b || !selection || selection.panel !== panel) {
showToast(t("xlsx.format_merge_needs_range"), "info");
return null;
}
return { panel, b };
};
const removeDuplicateRows = async () => {
const sel = requireSelection();
if (!sel) return;
const { panel, b } = sel;
const seen = new Set();
const duplicates = [];
for (let r = b.r1; r <= b.r2; r++) {
const key = [];
let filled = false;
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
const text = td ? cellTextOf(td).trim() : "";
if (text) filled = true;
key.push(text);
}
// Blank rows are not duplicates of each other (Sheets ignores them too).
if (!filled) continue;
const signature = key.join("\u0000");
if (seen.has(signature)) duplicates.push(r);
else seen.add(signature);
}
if (!duplicates.length) { showToast(t("xlsx.mi_cleanup_dupes_none"), "info"); return; }
const ok = await showConfirm({
title: t("xlsx.mi_cleanup_dupes"),
message: t("xlsx.mi_cleanup_dupes_done", { n: duplicates.length }),
confirmLabel: t("common.confirm"),
danger: true,
});
if (!ok) return;
// Bottom-up: deleting the last duplicate first keeps the earlier indexes valid.
const actions = duplicates.slice().sort((x, y) => y - x)
.map((row) => ({ op: "row_delete", sheet: sheetNameOf(), at: row, count: 1 }));
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
};
const trimSelection = () => {
const sel = requireSelection();
if (!sel) return;
const { panel, b } = sel;
let cleaned = 0;
for (let r = b.r1; r <= b.r2; r++) {
for (let c = b.c1; c <= b.c2; c++) {
const td = findTd(panel, r, c);
if (!td || td.dataset.editing === "1") continue;
const raw = cellRaw(td);
if (!raw) continue;
const next = isFormulaText(raw) ? raw : raw.trim().replace(/[ \t]+/g, " ");
if (next === raw) continue;
setCellValue(td, next);
cleaned++;
}
}
refreshPanelFormulas(panel);
refreshSaveState();
showToast(t("xlsx.mi_cleanup_trim_done", { n: cleaned }), "success");
};
const splitTextToColumns = async () => {
const panel = visiblePanel();
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
if (!td) return;
const source = cellTextOf(td).trim();
if (!source) return;
const separator = await showPrompt({
title: t("xlsx.mi_split"),
message: t("xlsx.mi_split_sep"),
value: ",",
});
if (separator === null || separator === "") return;
const start = parseRef(cellName(td));
if (!start) return;
const parts = source.split(separator).map((part) => part.trim());
let filled = 0;
parts.forEach((value, i) => {
const target = findTd(panel, start.row, start.col + i);
if (!target) return;
if (setCellValue(target, value)) filled++;
});
refreshPanelFormulas(panel);
refreshSaveState();
showToast(t("xlsx.mi_split_done", { n: filled }), "success");
};
// ── File actions that need their own endpoint ──────────────────────────
const fileRename = async () => {
const current = fileNameOf();
const next = await showPrompt({
title: t("xlsx.mi_rename"),
message: t("xlsx.rename_prompt"),
value: current,
});
if (!next || next === current) return;
try {
await api(`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, {
method: "PATCH",
body: JSON.stringify({ path: data.path, new_name: next }),
});
showToast(t("xlsx.rename_done", { name: next }), "success");
} catch (err) {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
};
const fileTrash = async () => {
const name = fileNameOf();
const ok = await showConfirm({
title: t("xlsx.mi_trash"),
message: t("xlsx.trash_confirm", { name }),
confirmLabel: t("xlsx.mi_trash"),
danger: true,
});
if (!ok) return;
try {
await api(`/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`, {
method: "DELETE",
});
showToast(t("xlsx.trash_done"), "success");
} catch (err) {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
};
const fileHistory = async () => {
try {
const mod = await import("./backups.js");
await mod.openBackupViewer(data.vault, data.path);
} catch (_) {
showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_history") }), "info");
}
};
const fileDetails = () => {
const dirty = dirtyCount();
showConfirm({
title: t("xlsx.details_title"),
message: t("xlsx.details_body", {
vault: data.vault || "—",
path: data.path || "—",
sheets: sheets.length,
cells: area.querySelectorAll(".xlsx-table td[data-cell]").length,
revision: revision || "—",
dirty: String(dirty),
}),
confirmLabel: t("common.close"),
});
};
const openSettings = () => {
const btn = document.getElementById("config-open-btn");
if (btn) btn.click();
else showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_settings") }), "info");
};
const showWhatsNew = async () => {
try {
const health = await api("/api/health");
showConfirm({
title: t("xlsx.version_title"),
message: t("xlsx.version_body", {
version: health.version || "—",
date: health.git_commit ? String(health.git_commit).slice(0, 8) : "",
status: health.status || "ok",
}),
confirmLabel: t("common.close"),
});
} catch (_) {
showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_whatsnew") }), "info");
}
};
const reportProblem = async () => {
const report = [
`ObsiGate ${window.__OBSIGATE_VERSION || "?"}`,
navigator.userAgent,
`vault=${data.vault || ""} path=${data.path || ""}`,
`sheets=${sheets.length} revision=${revision || "-"} dirty=${dirtyCount()}`,
].join("\n");
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(report);
showToast(t("xlsx.report_copied"), "success");
return;
}
} catch (_) { /* clipboard denied — fall through */ }
showConfirm({
title: t("xlsx.mi_report"),
message: report,
confirmLabel: t("common.close"),
});
};
/** action name -> implementation. Mirrors SHEETS_MENUS in menus.js. */
const menuActions = {
// Fichier
share: () => openShareDialog(data.vault, data.path),
download: downloadOriginalFile,
"export-csv": exportSheetCsv,
"export-md": () => exportDocument("md"),
"export-html": () => exportDocument("html"),
rename: fileRename,
trash: fileTrash,
history: fileHistory,
details: fileDetails,
settings: openSettings,
print: () => printSheet(),
// Modifier
undo: () => { undo(); },
redo: () => { redo(); },
cut: () => { if (editable) cutSelection(); },
copy: () => { copySelection(); },
paste: () => pasteFromClipboard(),
"paste-values": pasteValuesOnly,
"delete-rows": () => {
const b = selectionBounds();
if (!b) return;
structureAction(
{ op: "row_delete", sheet: sheetNameOf(), at: b.r1, count: b.r2 - b.r1 + 1 },
t("xlsx.confirm_delete_rows", { n: b.r2 - b.r1 + 1 }),
);
},
"delete-cols": () => {
const b = selectionBounds();
if (!b) return;
structureAction(
{ op: "col_delete", sheet: sheetNameOf(), at: b.c1, count: b.c2 - b.c1 + 1 },
t("xlsx.confirm_delete_cols", { n: b.c2 - b.c1 + 1 }),
);
},
"clear-contents": () => {
const b = selectionBounds();
if (b) clearContents(visiblePanel(), b);
},
"select-all": () => selectAllCells(visiblePanel()),
"find-replace": () => replaceFlow(),
// Affichage
freeze: () => freezeAtSelection(),
"freeze-row": () => {
const ref = activeRefOf();
runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: `A${ref.row}` }]);
},
"freeze-col": () => {
const ref = activeRefOf();
runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: `${columnName(ref.col)}1` }]);
},
unfreeze: () => runStyle([{ op: "freeze", sheet: sheetNameOf(), cell: "" }]),
"toggle-formula-bar": () => {
const bar = area.querySelector(".xlsx-formula-bar");
if (bar) bar.hidden = !bar.hidden;
},
"toggle-grid": toggleGrid,
"toggle-headers": () => viewerEl.classList.toggle("xlsx-no-headers"),
dashboard: () => { if (dashBtn) dashBtn.click(); },
zoom: (arg) => { const z = Number(arg); if (Number.isFinite(z)) applyZoom(z); },
fullscreen: toggleFullscreen,
// Insertion
"insert-rows-above": () => insertRows(-1),
"insert-rows-below": () => insertRows(1),
"insert-cols-left": () => insertCols(-1),
"insert-cols-right": () => insertCols(1),
"insert-cells": () => {
const ref = activeRefOf();
const name = sheetNameOf();
structureAction({ op: "row_insert", sheet: name, at: ref.row });
structureAction({ op: "col_insert", sheet: name, at: ref.col });
},
link: () => insertLinkFlow(),
comment: () => editCommentFlow(),
function: insertFunctionFlow,
// Format
numfmt: (arg) => runCellStyle({ number_format: arg }),
"theme-menu": (arg, item) => fillThemeSection(item),
"apply-theme": (arg) => {
import("./themes.js")
.then((mod) => mod.setTheme(arg))
.catch(() => showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_theme") }), "info"));
},
bold: () => runCellStyle({ bold: !hasBold() }),
italic: () => runCellStyle({ italic: !hasItalic() }),
underline: () => runCellStyle({ underline: !hasUnderline() }),
strike: () => runCellStyle({ strike: !hasStrike() }),
fontsize: (arg) => runCellStyle({ font_size: Number(arg) }),
align: (arg) => runCellStyle({ align: arg }),
valign: (arg) => runCellStyle({ valign: arg }),
"wrap-on": () => runCellStyle({ wrap: true }),
"wrap-off": () => runCellStyle({ wrap: false }),
rotate: (arg) => runCellStyle({ rotation: Number(arg) }),
"clear-format": () => runCellStyle({
bold: false, italic: false, underline: false, strike: false,
// Pas de `valign` : le backend ne connaît que top/middle/bottom et
// rejette `""` en 400 (même cause que le bug « Paint Format »).
font_color: "", fill_color: "", align: "left",
wrap: false, rotation: 0, number_format: "General",
}),
banded: applyBandedRows,
// Tailles (Format ▸ Redimensionner)
"col-width": () => promptColumnWidth(),
"row-height": () => promptRowHeight(),
"col-fit": () => autoFitSelection("col"),
"row-fit": () => autoFitSelection("row"),
// Données
"sort-asc": () => sortFromToolbar("asc"),
"sort-desc": () => sortFromToolbar("desc"),
"sort-reset": () => sortResetBtn.click(),
"filter-column": () => {
const panel = visiblePanel();
const td = activeTd && activeTd.closest(".xlsx-panel") === panel ? activeTd : null;
const value = td ? cellTextOf(td).trim() : "";
if (!value) { showToast(t("xlsx.find_no_match"), "info"); return; }
panelEls.forEach((p) => applyFilter(p, p === panel ? value : ""));
findInput.value = value;
runFind();
},
"named-ranges": () => { if (dashBtn) dashBtn.click(); },
"cleanup-dupes": removeDuplicateRows,
"cleanup-trim": trimSelection,
"split-text": splitTextToColumns,
// Outils
autocomplete: (arg) => {
const on = arg !== "false";
formulaInput.setAttribute("list", on ? "xlsx-function-list" : "");
showToast(on ? t("xlsx.mi_autocomplete_on") : t("xlsx.mi_autocomplete_off"), "info");
},
a11y: () => {
const on = viewerEl.classList.toggle("xlsx-a11y");
showToast(on ? t("xlsx.mi_a11y_on") : t("xlsx.mi_a11y_off"), "info");
},
// Gemini
gemini: () => { openAssistant(); },
// Aide
help: () => {
const btn = document.getElementById("help-open-btn");
if (btn) btn.click();
else showToast(t("xlsx.menu_unavailable", { item: t("xlsx.mi_help_sheets") }), "info");
},
whatsnew: showWhatsNew,
shortcuts: () => showConfirm({
title: t("xlsx.shortcuts_title"),
message: t("xlsx.shortcuts_body"),
confirmLabel: t("common.close"),
}),
report: reportProblem,
};
/** Modifier ▸ Rechercher et remplacer. */
const replaceFlow = async () => {
const panel = visiblePanel();
if (!panel) return;
const needle = await showPrompt({
title: t("xlsx.mi_replace"),
message: t("xlsx.mi_replace_find"),
value: findInput.value,
});
if (needle === null || !String(needle).length) return;
const replacement = await showPrompt({
title: t("xlsx.mi_replace"),
message: t("xlsx.mi_replace_with"),
});
if (replacement === null) return;
const from = String(needle);
const to = String(replacement);
const scope = createScope({ readRaw: readRawCell, sheet: panelSheetName(panel), locale: formulaLocale() });
const matches = panelEls.reduce((acc, p) => acc.concat(
[...p.querySelectorAll(".xlsx-table td[data-cell]")].filter(
(td) => td.textContent.toLowerCase().includes(from.toLowerCase()),
),
), []);
if (!matches.length) { showToast(t("xlsx.mi_replace_none"), "info"); return; }
let replaced = 0;
matches.forEach((td) => {
const raw = cellRaw(td);
const next = isFormulaText(raw)
? raw.replace(new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), to)
: cellTextOf(td).replace(new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), to);
if (next !== raw && setCellValue(td, next)) replaced++;
});
panelEls.forEach(refreshPanelFormulas);
refreshSaveState();
if (replaced) showToast(t("xlsx.mi_replace_done", { n: replaced }), "success");
else showToast(t("xlsx.mi_replace_none"), "info");
};
// ── open / close (same dismissal contract as the other menus, #179) ─────
const openers = new Map();
const closeMenu = (btn) => {
const dismiss = openers.get(btn);
if (dismiss) { dismiss(); openers.delete(btn); }
};
const closeAllMenus = () => [...openers.keys()].forEach(closeMenu);
menuBar.querySelectorAll(".xlsx-menubtn").forEach((btn) => {
const drop = document.getElementById(btn.getAttribute("aria-controls"));
if (!drop) return;
enableArrowNav(drop, ".xlsx-menu-item");
btn.addEventListener("click", () => {
if (openers.has(btn)) { closeMenu(btn); return; }
closeAllMenus();
drop.style.display = "block";
btn.setAttribute("aria-expanded", "true");
const toolbar = btn.closest(".xlsx-toolbar") || area;
anchorUnder(drop, toolbar, btn);
const first = drop.querySelector(".xlsx-menu-item");
if (first && typeof first.focus === "function") first.focus({ preventScroll: true });
openers.set(btn, trackDismissable(drop, {
toggle: btn,
onClose: () => {
openers.delete(btn);
drop.style.display = "none";
btn.setAttribute("aria-expanded", "false");
},
}));
});
});
// Sheets accordion: one section open per level, the others collapse.
menuBar.addEventListener("click", (e) => {
const row = e.target.closest ? e.target.closest(".xlsx-menu-item[aria-haspopup='menu']") : null;
if (!row) return;
// stopImmediatePropagation, not stopPropagation: the dispatcher below
// listens on the SAME element, and a section row must not be treated as
// a plain command (it would close the menu it just filled).
e.stopImmediatePropagation();
// A section can also declare an action (the empty « Thème » one): run it
// instead of toggling, it fills the section itself.
const fill = row.dataset.xlsxAction ? menuActions[row.dataset.xlsxAction] : null;
if (fill) { fill(row.dataset.xlsxArg, row); return; }
const sub = row.nextElementSibling;
if (!sub || !sub.classList.contains("xlsx-menu-sub")) return;
const wasOpen = row.getAttribute("aria-expanded") === "true";
row.parentElement.querySelectorAll(".xlsx-menu-item[aria-haspopup='menu']").forEach((other) => {
if (other === row) return;
other.setAttribute("aria-expanded", "false");
const otherSub = other.nextElementSibling;
if (otherSub && otherSub.classList.contains("xlsx-menu-sub")) otherSub.hidden = true;
});
row.setAttribute("aria-expanded", String(!wasOpen));
sub.hidden = wasOpen;
});
// One delegated dispatcher for every entry of every menu.
menuBar.addEventListener("click", (e) => {
const item = e.target.closest ? e.target.closest("[data-xlsx-action], [data-xlsx-unavailable]") : null;
if (!item) return;
const labelNode = item.querySelector(".xlsx-menu-label");
const label = labelNode ? labelNode.textContent : "";
const kind = item.dataset.xlsxExport;
// Any pick dismisses the menu — including an entry whose only job is
// to announce it is unavailable (#185).
closeAllMenus();
if (!kind && item.hasAttribute("data-xlsx-unavailable")) {
showToast(t("xlsx.menu_unavailable", { item: label }), "info");
return;
}
if (kind) {
if (kind === "print") printSheet();
else exportDocument(kind);
return;
}
const run = menuActions[item.dataset.xlsxAction];
if (!run) { showToast(t("xlsx.menu_unavailable", { item: label }), "info"); return; }
run(item.dataset.xlsxArg, item);
});
}
const buildContextItems = (target) => {
const panel = target.panel;
const sheetName = sheets[Number(panel.dataset.sheet)]?.name || "";
const b = selectionBounds();
if (!b) return [];
const rowCount = b.r2 - b.r1 + 1;
const colCount = b.c2 - b.c1 + 1;
const items = [];
// Clic droit sur un EN-TÊTE (Excel / Sheets) : les commandes de taille
// d'abord — le dialogue et l'ajustement automatique portent sur la colonne
// ou la ligne cliquée (selectForTarget() en a déjà couvert toute la
// longueur / largeur).
if (editable && target.kind === "col") {
items.push({ icon: "move-horizontal", label: t("xlsx.format_col_width"), onClick: () => promptColumnWidth() });
items.push({ icon: "maximize-2", label: t("xlsx.format_col_fit"), onClick: () => autoFitSelection("col") });
items.push({ separator: true });
} else if (editable && target.kind === "row") {
items.push({ icon: "move-vertical", label: t("xlsx.format_row_height"), onClick: () => promptRowHeight() });
items.push({ icon: "maximize-2", label: t("xlsx.format_row_fit"), onClick: () => autoFitSelection("row") });
items.push({ separator: true });
}
// #156-A5 — clipboard entries first, in the spreadsheet order.
// #179 — every entry carries a representative Lucide icon.
items.push({ icon: "scissors", label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
items.push({ icon: "copy", label: t("xlsx.copy"), onClick: () => copySelection() });
items.push({ icon: "clipboard-paste", label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
items.push({ separator: true });
if (editable) {
items.push({ icon: "rows", label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ icon: "rows", label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ icon: "columns", label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ icon: "columns", label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ separator: true });
items.push({ icon: "trash-2", label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ icon: "trash-2", label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ separator: true });
}
if (!isCsv) {
items.push({ icon: "arrow-up", label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ icon: "arrow-down", label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
}
if (editable) {
items.push({ separator: true });
items.push({ icon: "eraser", label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
}
return items;
};
const openGridMenu = (x, y, target, byTouch = false) => {
selectForTarget(target);
openContextMenu(x, y, buildContextItems(target), { byTouch });
};
viewerEl.addEventListener("contextmenu", (e) => {
const target = contextTargetFrom(e.target);
if (!target) return;
e.preventDefault();
openGridMenu(e.clientX, e.clientY, target);
});
// Long-press (touch) — the mobile equivalent of a right-click.
let longPressTimer = null;
const cancelLongPress = () => {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
};
viewerEl.addEventListener("touchstart", (e) => {
const target = contextTargetFrom(e.target);
if (!target || !e.touches || !e.touches[0]) return;
const t0 = e.touches[0];
cancelLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
openGridMenu(t0.clientX, t0.clientY, target, true);
}, 500);
}, { passive: true });
viewerEl.addEventListener("touchmove", cancelLongPress, { passive: true });
viewerEl.addEventListener("touchend", cancelLongPress);
viewerEl.addEventListener("touchcancel", cancelLongPress);
// #182 — focus a cell WITHOUT letting its focus handler collapse the current
// range: the focus handler only syncs the formula bar, so the Name Box has to
// be restored to the range address afterwards (otherwise a drag or a
// Shift+click left `C3` in the box instead of `A1:C3`).
const focusKeepingRange = (td) => {
if (!td || !td.isConnected) return;
suppressSelectionReset = true;
td.focus();
suppressSelectionReset = false;
if (selection) activeCellEl.value = rangeLabel();
};
// Drag-to-select a rectangular range + Shift+click extension.
let dragAnchorTd = null;
let dragActive = false;
let dragExtended = false;
viewerEl.addEventListener("mousedown", (e) => {
// Clic droit sur la grille : le navigateur déplace le focus sur la cellule
// au moment du `mousedown`. Le handler `focus` de la cellule appellerait
// alors setActiveCell() et ÉCRASERAIT la sélection multiple AVANT que le
// menu contextuel ne s'ouvre — les entrées Copier / Coller / Effacer ne
// s'appliquaient plus qu'à la seule cellule sous le curseur. Le
// `preventDefault()` annule ce déplacement de focus : la plage survit
// jusqu'au choix d'une option (Google Sheets fait de même). Le
// déplacement de la sélection vers une cellule HORS plage reste géré par
// selectForTarget(), explicitement, à l'ouverture du menu.
if (e.button === 2) { e.preventDefault(); return; }
if (e.button !== 0) return;
const td = e.target.closest && e.target.closest("td[data-cell]");
if (!td) return;
const panel = td.closest(".xlsx-panel");
if (e.shiftKey && selection && selection.panel === panel) {
e.preventDefault();
selection.focus = parseRef(cellName(td)) || selection.focus;
applySelection();
// #182 — `preventDefault()` above suppresses the browser's own focus
// move, so `activeTd` and the real DOM focus used to diverge: the next
// click on the still-focused cell fired NO focus event and the range
// stayed stuck. Focusing here keeps both in step.
activeTd = td;
// #182 — `preventDefault()` above suppresses the browser's own focus
// move, so `activeTd` and the real DOM focus used to diverge: the next
// click on the still-focused cell fired NO focus event and the range
// stayed stuck. Focusing here keeps both in step — under
// `suppressSelectionReset`, so the focus handler does not collapse the
// range we just extended.
suppressSelectionReset = true;
td.focus();
suppressSelectionReset = false;
formulaInput.disabled = false;
formulaInput.value = barTextOf(td);
if (selection) activeCellEl.value = rangeLabel();
return;
}
// #181-A1 — a plain click SELECTS: drop any in-cell caret/text selection
// and leave edit mode, so no blinking cursor is left behind on the cell.
if (activeTd && isTypingInCell(activeTd)) {
commitEdit();
exitEditing(activeTd);
}
// #182 — a plain click ALWAYS collapses the selection to the clicked cell,
// even when that cell already had the focus (no focus event fires then,
// which is why a previously drawn range refused to clear). Doing it here
// rather than relying on `focus` makes deselection deterministic.
setActiveCell(td);
dragAnchorTd = td;
dragActive = true;
dragExtended = false;
});
viewerEl.addEventListener("mouseover", (e) => {
if (!dragActive || !dragAnchorTd) return;
const td = e.target.closest && e.target.closest("td[data-cell]");
if (!td || td === dragAnchorTd) return;
const panel = dragAnchorTd.closest(".xlsx-panel");
if (td.closest(".xlsx-panel") !== panel) return;
const a = parseRef(cellName(dragAnchorTd));
const f = parseRef(cellName(td));
if (!a || !f) return;
if (!dragExtended) {
dragExtended = true;
// Stop editing the anchor cell and drop any in-cell text selection.
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
if (document.getSelection) { const s = document.getSelection(); if (s && s.removeAllRanges) s.removeAllRanges(); }
viewerEl.classList.add("xlsx-selecting");
}
selection = { panel, anchor: a, focus: f };
applySelection();
});
const endDrag = () => {
if (dragActive && dragExtended && dragAnchorTd) {
activeTd = dragAnchorTd;
// #182 — no focus() here on purpose: a drag does not move the focus in
// a browser either, and stealing it would collapse the range we just
// drew (the cell's focus handler resets the selection to itself).
// Deselection no longer depends on focus events — the `mousedown`
// handler collapses the range explicitly.
formulaInput.disabled = false;
formulaInput.value = barTextOf(dragAnchorTd);
// #182 — the Name Box keeps showing the range, not just its anchor.
if (selection) activeCellEl.value = rangeLabel();
}
dragActive = false;
dragExtended = false;
dragAnchorTd = null;
viewerEl.classList.remove("xlsx-selecting");
};
viewerEl.addEventListener("mouseup", endDrag);
viewerEl.addEventListener("mouseleave", endDrag);
safeCreateIcons();
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
// instead of being wiped mid-session by a tab switch / sidebar click.
if (isInlineEditorActive()) detachInlineEditor();
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
// BUG-072 / #111 — any render that is not an image viewer starts from a clean
// viewer. Image→image navigation now happens in place (see renderImageViewer),
// so the viewer is never rebuilt between siblings.
if (!data.is_image) {
_imageViewerState.lightbox = false;
_imageViewerState.meta = false;
}
const area = getContentArea();
// #110 — if this render is about to replace the surface that currently hosts
// the shared media element, hand it back to the persistent dock first.
NowPlaying.handleRender(area, data);
// Handle PDF files — render in iframe with TOC sidebar
if (data.is_pdf) {
const pdfUrl = `/api/file/${encodeURIComponent(data.vault)}/pdf/stream?path=${encodeURIComponent(data.path)}`;
const pages = data.pdf_metadata && data.pdf_metadata.pages ? data.pdf_metadata.pages : '?';
const toc = data.pdf_toc || [];
let tocHtml = '';
if (toc.length > 0) {
tocHtml = '
Table des matières
';
for (const item of toc) {
const indent = (item.level - 1) * 16;
tocHtml += `
`;
}
}
// ── 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);
}
}