feat: undo/redo, chargement IntersectionObserver et ARIA pour la visionneuse XLSX #154
This commit is contained in:
+98
-6
@@ -1113,6 +1113,19 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
}
|
||||
};
|
||||
// #154-A4 — ARIA grid semantics (the backend renders a plain <table>).
|
||||
const applyGridA11y = (panel) => {
|
||||
const table = panel.querySelector(".xlsx-table");
|
||||
if (!table) return;
|
||||
table.setAttribute("role", "grid");
|
||||
table.querySelectorAll("thead th").forEach((th) => th.setAttribute("role", "columnheader"));
|
||||
table.querySelectorAll("tbody tr").forEach((tr) => {
|
||||
tr.setAttribute("role", "row");
|
||||
tr.querySelectorAll("th, td").forEach((cell) => {
|
||||
cell.setAttribute("role", cell.tagName === "TH" ? "rowheader" : "gridcell");
|
||||
});
|
||||
});
|
||||
};
|
||||
const panels = isCsv
|
||||
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
|
||||
: sheets.map((s, i) =>
|
||||
@@ -1148,6 +1161,14 @@ export function renderXlsxViewer(area, data) {
|
||||
// later into the dedicated (empty) “view” group.
|
||||
const actionGroups = [];
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
|
||||
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
|
||||
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
@@ -1392,6 +1413,7 @@ export function renderXlsxViewer(area, data) {
|
||||
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
|
||||
applySheetMeta(panel);
|
||||
}
|
||||
applyGridA11y(panel);
|
||||
safeCreateIcons();
|
||||
};
|
||||
|
||||
@@ -1415,11 +1437,20 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
|
||||
wrapper.addEventListener("scroll", () => {
|
||||
if (loading || done) return;
|
||||
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
|
||||
});
|
||||
// #154-A4 — prefer IntersectionObserver (less main-thread work than a
|
||||
// scroll listener); the scroll listener stays as a fallback for
|
||||
// environments without IO (JSDOM).
|
||||
if (typeof IntersectionObserver === "function") {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) loadMore();
|
||||
}, { root: wrapper, rootMargin: "120px" });
|
||||
io.observe(foot);
|
||||
} else {
|
||||
wrapper.addEventListener("scroll", () => {
|
||||
if (loading || done) return;
|
||||
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
|
||||
@@ -1517,6 +1548,65 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── #154-A4 — local undo/redo for cell edits ──────────────────────────
|
||||
// One command per committed edit (a cell loses focus with a new value).
|
||||
// Structure changes re-render from the server, which rebuilds the whole
|
||||
// viewer and therefore clears the stacks (they are per-render).
|
||||
const undoStack = [];
|
||||
const redoStack = [];
|
||||
let pendingEdit = null;
|
||||
const undoBtn = area.querySelector("#xlsx-undo-btn");
|
||||
const redoBtn = area.querySelector("#xlsx-redo-btn");
|
||||
const updateUndoButtons = () => {
|
||||
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
|
||||
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
|
||||
};
|
||||
const commitEdit = () => {
|
||||
if (!pendingEdit) return;
|
||||
const { td, before } = pendingEdit;
|
||||
pendingEdit = null;
|
||||
const after = td.textContent;
|
||||
if (before === after) return;
|
||||
undoStack.push({ sheet: td.closest(".xlsx-panel")?.dataset.sheet, ref: td.dataset.cell, before, after });
|
||||
redoStack.length = 0;
|
||||
updateUndoButtons();
|
||||
};
|
||||
const beginEdit = (td) => {
|
||||
if (pendingEdit && pendingEdit.td !== td) commitEdit();
|
||||
if (!pendingEdit) pendingEdit = { td, before: td.textContent };
|
||||
};
|
||||
const applyHistory = (entry, value) => {
|
||||
const panel = area.querySelector(`.xlsx-panel[data-sheet="${entry.sheet}"]`) || panelEls[0];
|
||||
const td = panel?.querySelector(`td[data-cell="${entry.ref}"]`);
|
||||
if (!td) return;
|
||||
td.textContent = value;
|
||||
td.classList.toggle("xlsx-dirty", value !== (td.dataset.orig ?? ""));
|
||||
if (td === activeTd) syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
};
|
||||
const undo = () => {
|
||||
const entry = undoStack.pop();
|
||||
if (!entry) return;
|
||||
applyHistory(entry, entry.before);
|
||||
redoStack.push(entry);
|
||||
updateUndoButtons();
|
||||
};
|
||||
const redo = () => {
|
||||
const entry = redoStack.pop();
|
||||
if (!entry) return;
|
||||
applyHistory(entry, entry.after);
|
||||
undoStack.push(entry);
|
||||
updateUndoButtons();
|
||||
};
|
||||
if (undoBtn) undoBtn.addEventListener("click", undo);
|
||||
if (redoBtn) redoBtn.addEventListener("click", redo);
|
||||
area.querySelector(".xlsx-viewer").addEventListener("keydown", (e) => {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||||
else if (key === "y") { e.preventDefault(); redo(); }
|
||||
});
|
||||
|
||||
const setupCell = (td) => {
|
||||
td.contentEditable = "true";
|
||||
td.spellcheck = false;
|
||||
@@ -1530,7 +1620,8 @@ export function renderXlsxViewer(area, data) {
|
||||
syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
});
|
||||
td.addEventListener("focus", () => setActiveCell(td));
|
||||
td.addEventListener("focus", () => { beginEdit(td); setActiveCell(td); });
|
||||
td.addEventListener("blur", commitEdit);
|
||||
td.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
||||
if (e.key === "Escape") {
|
||||
@@ -1575,6 +1666,7 @@ export function renderXlsxViewer(area, data) {
|
||||
}
|
||||
panelEls.forEach(wireLazyRows);
|
||||
panelEls.forEach(applySheetMeta);
|
||||
panelEls.forEach(applyGridA11y);
|
||||
// The formula bar starts disabled: nothing is selected yet.
|
||||
setActiveCell(null);
|
||||
|
||||
|
||||
@@ -1831,6 +1831,8 @@
|
||||
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
|
||||
"xlsx.conflict_retry": "Retry",
|
||||
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
|
||||
"xlsx.undo": "Undo the last change",
|
||||
"xlsx.redo": "Redo the change",
|
||||
"xlsx.cached_value_title": "Last value calculated by Excel",
|
||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||
"xlsx.readonly_badge": "Read-only",
|
||||
|
||||
@@ -1831,6 +1831,8 @@
|
||||
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
|
||||
"xlsx.conflict_retry": "Réessayer",
|
||||
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
|
||||
"xlsx.undo": "Annuler la dernière modification",
|
||||
"xlsx.redo": "Rétablir la modification",
|
||||
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
|
||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||
"xlsx.readonly_badge": "Lecture seule",
|
||||
|
||||
Reference in New Issue
Block a user