feat: tri, filtre, recherche et export CSV dans la visionneuse XLSX #153
A13 (affichage seul, le classeur n'est jamais réécrit) : clic sur un en-tête pour trier la colonne (asc/desc, numérique si toutes les valeurs le sont, localeCompare fr sinon — les cellules modifiées suivent leur ligne) ; l'unique champ de recherche filtre les lignes ET surligne les occurrences (marque <mark>, navigation ↑/↓/Entrée, compteur i18n, insensible à la casse par défaut, bouton Aa) ; bouton « Réinitialiser » qui re-rend le fichier (vérité serveur) ; export CSV de la feuille visible (BOM UTF-8, échappement ;/" et retrait des ombres de valeurs calculées). Vérifié : xlsx-viewer.test.mjs 30/30 (5 nouveaux) + contre-preuves (tri neutralisé -> 1 échec, filtre neutralisé -> 1 échec), validate-imports, i18n parity (xlsx.find_*, xlsx.sort_*, xlsx.csv_export). 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -1074,6 +1074,9 @@ export function renderXlsxViewer(area, data) {
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
${lossWarning}
|
||||
@@ -1082,6 +1085,17 @@ export function renderXlsxViewer(area, data) {
|
||||
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
|
||||
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
||||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
||||
<span class="xlsx-find-group">
|
||||
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
|
||||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
|
||||
<span class="xlsx-find-count" id="xlsx-find-count"></span>
|
||||
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
|
||||
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
|
||||
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
|
||||
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
|
||||
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="xlsx-panels">${panels}</div>
|
||||
</div>`;
|
||||
@@ -1393,6 +1407,183 @@ export function renderXlsxViewer(area, data) {
|
||||
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
|
||||
});
|
||||
|
||||
// ── #153 A13 — sort / filter / find in the sheet + CSV export ──────────
|
||||
// ALL of these act on the RENDERED grid only: the workbook is never
|
||||
// rewritten by a sort or a filter (the save pipeline stays the only write
|
||||
// path, and the note in xlsx.sort_applied says so).
|
||||
const visiblePanel = () =>
|
||||
panelEls.find((p) => p.style.display !== "none") || panelEls[0];
|
||||
|
||||
// Sorting reorders <tr> rows by the text of one column. Dirty cells travel
|
||||
// with their row, so a sort never loses an unsaved edit.
|
||||
let sortState = null; // { col, dir } on the visible panel
|
||||
|
||||
const applySort = (panel, col, dir) => {
|
||||
const tbody = panel.querySelector(".xlsx-table tbody");
|
||||
if (!tbody) return;
|
||||
const rows = [...tbody.querySelectorAll("tr")];
|
||||
const 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 = "";
|
||||
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
|
||||
};
|
||||
|
||||
// Header click cycles: asc → desc → back to the sheet order.
|
||||
const wireHeaderSort = (panel) => {
|
||||
const thead = panel.querySelector(".xlsx-table thead");
|
||||
if (!thead || thead.dataset.sortWired) return;
|
||||
thead.dataset.sortWired = "1";
|
||||
thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
|
||||
th.style.cursor = "pointer";
|
||||
th.title = t("xlsx.sort_asc");
|
||||
th.addEventListener("click", () => {
|
||||
// The corner th is column 0, so the child index IS the column number.
|
||||
const col = [...th.parentElement.children].indexOf(th);
|
||||
const current = sortState && sortState.col === col ? sortState.dir : null;
|
||||
const dir = current === "asc" ? "desc" : "asc";
|
||||
applySort(panel, col, dir);
|
||||
th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Filter: rows whose cell text lacks the needle are hidden.
|
||||
const applyFilter = (panel, needle) => {
|
||||
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 = "";
|
||||
};
|
||||
|
||||
panelEls.forEach(wireHeaderSort);
|
||||
|
||||
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 = "";
|
||||
};
|
||||
|
||||
const runFind = () => {
|
||||
clearFind();
|
||||
const needle = findInput.value;
|
||||
if (!needle.trim()) return;
|
||||
const panel = visiblePanel();
|
||||
const hay = findCase ? (s) => s : (s) => s.toLowerCase();
|
||||
const n = hay(needle);
|
||||
panel.querySelectorAll(".xlsx-table td").forEach((td) => {
|
||||
const text = td.textContent;
|
||||
const pos = hay(text).indexOf(n);
|
||||
if (pos === -1 || !text) return;
|
||||
// Wrap the match in a <mark> by splitting the cell's first text node at
|
||||
// the hit (cells are text-first; the cached-value span stays untouched).
|
||||
const first = td.firstChild;
|
||||
if (first && first.nodeType === 3) {
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "xlsx-find-hit";
|
||||
mark.textContent = text.substr(pos, needle.length);
|
||||
const tail = first.splitText(pos);
|
||||
tail.splitText(needle.length);
|
||||
td.replaceChild(mark, tail);
|
||||
findMatches.push({ td, mark });
|
||||
}
|
||||
});
|
||||
findCount.textContent = findMatches.length
|
||||
? t("xlsx.find_count", { index: 1, count: findMatches.length })
|
||||
: t("xlsx.find_no_match");
|
||||
if (findMatches.length) focusMatch(0);
|
||||
};
|
||||
|
||||
const focusMatch = (i) => {
|
||||
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
|
||||
findIndex = (i + findMatches.length) % findMatches.length;
|
||||
const { td, mark } = findMatches[findIndex];
|
||||
mark.classList.add("xlsx-find-current");
|
||||
// scrollIntoView is missing in JSDOM; guard it (real browsers have it).
|
||||
if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" });
|
||||
findCount.textContent = t("xlsx.find_count", { index: findIndex + 1, count: findMatches.length });
|
||||
};
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
// CSV export of the visible sheet (post-trim, pre-save data).
|
||||
area.querySelector("#xlsx-csv-btn").addEventListener("click", () => {
|
||||
const panel = visiblePanel();
|
||||
const idx = Number(panel.dataset.sheet);
|
||||
const rows = [];
|
||||
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||||
rows.push(
|
||||
[...tr.querySelectorAll("td")].map((td) => {
|
||||
const clone = td.cloneNode(true);
|
||||
clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove());
|
||||
return clone.textContent;
|
||||
}),
|
||||
);
|
||||
});
|
||||
const csv = rows
|
||||
.map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";"))
|
||||
.join("\n");
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${sheets[idx]?.name || "feuille"}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user