|
|
|
@@ -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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|