feat: chargement à la demande des lignes cachées des tableurs #153
A9bis : sous une feuille tronquée, un pied de page « N lignes affichées sur M · Charger la suite » fetch la fenêtre suivante (limit=500) au clic ou à l'approche du bas du tableau (sentinelle de défilement, marge 120px). Les lignes ajoutées passent par le même pipeline d'édition que le rendu initial (setupCell factorisé) : éditables et sauvegardables immédiatement. Fetch échoué → bouton restauré (retry) + toast ; feuille complète → pied de page masqué (class done). Vérifié : xlsx-viewer.test.mjs 19/19 (5 nouveaux, contre-preuve wireLazyRows désactivé → 5 échecs), E2E 8/8 (A520 visible et éditable après clic), validate-imports 40 modules, unit.test.mjs 12/12, i18n parity. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
+83
-7
@@ -1085,14 +1085,80 @@ export function renderXlsxViewer(area, data) {
|
||||
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
|
||||
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
|
||||
|
||||
// #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");
|
||||
});
|
||||
// #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;
|
||||
|
||||
// Editable cells: Enter blurs, Escape reverts, paste stays single-line.
|
||||
area.querySelectorAll(".xlsx-table td").forEach((td) => {
|
||||
let offset = Number(meta.rows) || 0;
|
||||
const total = Number(meta.total_rows) || 0;
|
||||
let loading = false;
|
||||
let done = offset >= total;
|
||||
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "xlsx-load-more";
|
||||
const refreshFoot = () => {
|
||||
foot.textContent = done
|
||||
? ""
|
||||
: `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`;
|
||||
foot.classList.toggle("done", done);
|
||||
};
|
||||
refreshFoot();
|
||||
foot.addEventListener("click", () => { if (!done) loadMore(); });
|
||||
wrapper.insertAdjacentElement("afterend", foot);
|
||||
|
||||
const appendWindow = (win) => {
|
||||
const doc = new DOMParser().parseFromString(`<table>${win.html}</table>`, "text/html");
|
||||
// No `tbody` selector: the fragment embeds its own wrapper div, so the
|
||||
// parse yields bare `<tr>` inside `<table>` (the rows we want) — while
|
||||
// any `tbody` in the fragment belongs to the *embedded* wrapper table.
|
||||
doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr));
|
||||
offset = win.offset + win.rows;
|
||||
done = !win.has_more;
|
||||
refreshFoot();
|
||||
// The new rows must behave like the initial ones: contenteditable,
|
||||
// dirty tracking, cached-value tooltip, icons in the fresh footnote.
|
||||
panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell);
|
||||
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
|
||||
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
|
||||
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
});
|
||||
};
|
||||
|
||||
const setupCell = (td) => {
|
||||
td.contentEditable = "true";
|
||||
td.spellcheck = false;
|
||||
td.dataset.orig = td.textContent;
|
||||
@@ -1113,8 +1179,18 @@ export function renderXlsxViewer(area, data) {
|
||||
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " ");
|
||||
document.execCommand("insertText", false, text);
|
||||
});
|
||||
};
|
||||
|
||||
// #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, paste stays single-line.
|
||||
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
||||
panelEls.forEach(wireLazyRows);
|
||||
|
||||
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
const idx = tab.dataset.sheet;
|
||||
|
||||
@@ -1833,6 +1833,9 @@
|
||||
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
||||
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
||||
"xlsx.truncated_hint": "Cells outside the displayed area cannot be edited here; the workbook is unchanged.",
|
||||
"xlsx.load_more": "Load more",
|
||||
"xlsx.loading_more": "Loading…",
|
||||
"xlsx.load_error": "Could not load the remaining rows",
|
||||
"xlsx.feature_cached_values": "cached values",
|
||||
"xlsx.feature_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
|
||||
@@ -1833,6 +1833,9 @@
|
||||
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
||||
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
|
||||
"xlsx.truncated_hint": "Les cellules hors de l'affichage ne sont pas éditables ici ; le classeur n'est pas modifié.",
|
||||
"xlsx.load_more": "Charger la suite",
|
||||
"xlsx.loading_more": "Chargement…",
|
||||
"xlsx.load_error": "Chargement de la suite impossible",
|
||||
"xlsx.feature_cached_values": "valeurs calculées",
|
||||
"xlsx.feature_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
|
||||
@@ -11089,6 +11089,30 @@ body.desktop-mode .editor-container {
|
||||
color: var(--accent, #4a90d9);
|
||||
}
|
||||
|
||||
/* #153 A9bis — “charger la suite” footnote under a truncated sheet. Also the
|
||||
scroll sentinel target: clickable whole, disabled look once the sheet is
|
||||
fully loaded. */
|
||||
.xlsx-load-more {
|
||||
display: block;
|
||||
margin: 6px 0 10px;
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.xlsx-load-more:hover {
|
||||
border-color: var(--accent, #4a90d9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-load-more.done {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.xlsx-formula-toggle {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user