feat: #152 viewer XLSX — affichage multi-feuilles, édition des cellules et téléchargement des .xlsx

- backend/xlsx_reader.py : rend openpyxl en tableaux HTML (plafond 500x40 par feuille)
- PUT /api/file/{vault}/xlsx/save : service edit_xlsx_cells (backup avant écriture, refs A1 validées)
- frontend : renderXlsxViewer (onglets, contenteditable, sauvegarde par feuille)
- docs : CHANGELOG [Unreleased], Roadmap index #152, archive, README FR/EN, exemple OpenAPI
This commit is contained in:
2026-09-25 20:30:45 -04:00
parent e20fd6bf97
commit 8611416670
18 changed files with 636 additions and 13 deletions
+110
View File
@@ -995,6 +995,110 @@ export function renderVideoViewer(area, data) {
}
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
// Cells are contenteditable; edits are collected per sheet and sent to
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
// saved back as formulas (no client-side recalculation — ceiling accepted).
function renderXlsxViewer(area, data) {
const sheets = data.xlsx_sheets || [];
const tabs = sheets.length > 1
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
).join("")}</div>`
: "";
const panels = sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
).join("");
area.innerHTML = `
<div class="xlsx-viewer">
<div class="xlsx-toolbar">
${tabs}
<span class="xlsx-toolbar-actions">
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
</span>
</div>
<div class="xlsx-panels">${panels}</div>
</div>`;
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
// Editable cells: Enter blurs, Escape reverts, paste stays single-line.
area.querySelectorAll(".xlsx-table td").forEach((td) => {
td.contentEditable = "true";
td.spellcheck = false;
td.dataset.orig = td.textContent;
td.addEventListener("input", () => {
td.classList.add("xlsx-dirty");
refreshSaveState();
});
td.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); td.blur(); }
if (e.key === "Escape") {
td.textContent = td.dataset.orig;
td.classList.remove("xlsx-dirty");
refreshSaveState();
}
});
td.addEventListener("paste", (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " ");
document.execCommand("insertText", false, text);
});
});
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const idx = 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"; });
});
});
saveBtn.addEventListener("click", async () => {
// One PUT per sheet (dirty cells can span tabs before a save).
const jobs = panelEls
.map((panel) => {
const cells = {};
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells };
})
.filter((job) => Object.keys(job.cells).length);
if (!jobs.length) return;
saveBtn.disabled = true;
try {
for (const job of jobs) {
await api(`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
});
}
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
td.classList.remove("xlsx-dirty");
td.dataset.orig = td.textContent;
});
refreshSaveState();
showToast(t("editor.saved"), "success");
} catch (err) {
refreshSaveState();
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
});
area.querySelector("#xlsx-download-btn").addEventListener("click", () => {
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
});
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
@@ -1058,6 +1162,12 @@ export function renderFile(data) {
return;
}
// Handle Excel .xlsx — editable table view (display / edit / download)
if (data.is_xlsx) {
renderXlsxViewer(area, data);
return;
}
// Handle Excalidraw — render in iframe editor
if (data.is_excalidraw) {
renderExcalidraw(area, data, data.vault, data.path);
+63
View File
@@ -10924,6 +10924,69 @@ body.desktop-mode .editor-container {
background: var(--surface);
}
/* ── XLSX Viewer ── */
.xlsx-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.xlsx-toolbar-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.xlsx-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.xlsx-tab {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
border-radius: 4px;
padding: 4px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.xlsx-tab.active {
background: var(--accent, #4a90d9);
border-color: var(--accent, #4a90d9);
color: #fff;
}
.xlsx-table th.xlsx-corner,
.xlsx-table th.xlsx-rownum {
background: var(--surface);
color: var(--text-secondary);
font-weight: 400;
text-align: right;
padding: 6px 8px;
border-bottom: 2px solid var(--border);
border-right: 1px solid var(--border-light, var(--border));
position: sticky;
left: 0;
z-index: 1;
}
.xlsx-table th.xlsx-corner {
left: 0;
top: 0;
z-index: 2;
}
.xlsx-table td[contenteditable] {
cursor: text;
min-width: 40px;
white-space: pre-wrap;
}
.xlsx-table td[contenteditable]:focus {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
.xlsx-table td.xlsx-dirty {
background: rgba(255, 196, 0, 0.18);
}
/* ── JSON Viewer ── */
.json-viewer {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;