feat: dialogues themes et conflits non bloquants pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Canceled after 42s

This commit is contained in:
2026-09-29 14:50:47 -04:00
parent 605060c51d
commit 8da65611cb
18 changed files with 368 additions and 77 deletions
+82
View File
@@ -1143,6 +1143,88 @@ const FileOperations = {
};
// ---------------------------------------------------------------------------
// Generic themed dialogs (#154-A2)
// ---------------------------------------------------------------------------
// Promise-based replacements for window.confirm() / window.prompt() so the
// Excel viewer's structure actions and lossy-write confirmations stay inside
// the app theme (and are keyboard accessible) instead of native dialogs.
function _closeDialog(overlay, resolve, value) {
overlay.classList.remove("active");
if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey);
setTimeout(() => overlay.remove(), 200);
resolve(value);
}
function _openDialog(innerHtml) {
const overlay = document.createElement("div");
overlay.className = "obsigate-modal-overlay";
const modal = document.createElement("div");
modal.className = "obsigate-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.innerHTML = innerHtml;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add("active"), 10);
return { overlay, modal };
}
/** Themed replacement for window.confirm(). Resolves to a boolean. */
export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body"><p class="modal-confirm-text">${escapeHtml(message)}</p></div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn ${danger ? "danger" : "primary"}" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false));
overlay._onKey = (e) => {
if (e.key === "Escape") done(false);
else if (e.key === "Enter") done(true);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20);
});
}
/** Themed replacement for window.prompt(). Resolves to the string (or null). */
export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
${message ? `<label class="modal-label">${escapeHtml(message)}</label>` : ""}
<input type="text" class="modal-input" data-dialog="input" spellcheck="false"
value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" />
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn primary" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const input = modal.querySelector('[data-dialog="input"]');
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null));
overlay._onKey = (e) => {
if (e.key === "Escape") done(null);
else if (e.key === "Enter") done(input.value);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => { input.focus(); input.select(); }, 20);
});
}
// ---------------------------------------------------------------------------
// Find in Page Manager
// ---------------------------------------------------------------------------
+94 -15
View File
@@ -3,7 +3,7 @@ import { api, AuthManager } from './auth.js';
import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
import { isInlineEditorActive, queryEditor } from './editor-inline.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard, showConfirm, showPrompt } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
@@ -1266,7 +1266,41 @@ export function renderXlsxViewer(area, data) {
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; };
const refreshSaveState = () => {
const dirty = dirtyCount() > 0;
saveBtn.disabled = !dirty;
saveBtn.classList.toggle("is-dirty", dirty);
// #154-A2 — star a sheet tab that holds unsaved edits.
panelEls.forEach((panel) => {
const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`);
if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0);
});
};
// #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept
// and the user can retry the save without losing their work.
const bannerHost = document.createElement("div");
bannerHost.className = "xlsx-banner-host";
area.querySelector(".xlsx-panels").before(bannerHost);
const showConflict = (msg) => {
bannerHost.innerHTML = "";
const banner = document.createElement("div");
banner.className = "xlsx-banner xlsx-banner-conflict";
banner.setAttribute("role", "alert");
banner.innerHTML = `<i data-lucide="git-merge" class="xlsx-banner-icon"></i>
<span class="xlsx-banner-text">${escapeHtml(msg)}</span>
<button class="btn-action xlsx-banner-retry" type="button">${escapeHtml(t("xlsx.conflict_retry"))}</button>
<button class="btn-action xlsx-banner-dismiss" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>`;
banner.querySelector(".xlsx-banner-retry").addEventListener("click", () => {
bannerHost.innerHTML = "";
saveBtn.click();
});
banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => {
bannerHost.innerHTML = "";
});
bannerHost.appendChild(banner);
safeCreateIcons();
};
// #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
@@ -1570,7 +1604,13 @@ export function renderXlsxViewer(area, data) {
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err;
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (!proceed) throw err;
lossyConfirmed = true;
force = true;
continue;
@@ -1591,6 +1631,9 @@ export function renderXlsxViewer(area, data) {
// A refused confirmation is a decision, not a failure: neutral toast.
if (err && err.code === "xlsx_lossy_content") {
showToast(t("xlsx.lossy_cancelled"), "info");
} else if (err && err.code === "conflict") {
// #154-A2 — non-blocking: the edits are kept, the user may retry.
showConflict(t("xlsx.conflict_msg"));
} else {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
@@ -1774,13 +1817,17 @@ export function renderXlsxViewer(area, data) {
renderXlsxViewer(area, fresh);
};
const structureError = (err, retryActions) => {
const structureError = async (err, retryActions) => {
if (err && err.code === "xlsx_lossy_content") {
const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (confirm(t("xlsx.lossy_confirm", { features: labels }))) {
return putStructure(retryActions, true); // re-emitted with force
}
const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (proceed) return putStructure(retryActions, true); // re-emitted with force
showToast(t("xlsx.lossy_cancelled"), "info");
return null;
}
@@ -1788,11 +1835,14 @@ export function renderXlsxViewer(area, data) {
return null;
};
// #154-A1 — the “+” button mirrors the structure menu's « Ajouter une
// #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une
// feuille » item, right where the tabs are (Excel-like).
const tabAddBtn = area.querySelector("#xlsx-tab-add");
if (tabAddBtn) tabAddBtn.addEventListener("click", async () => {
const name = prompt(t("xlsx.structure_prompt_add"));
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
@@ -1820,26 +1870,43 @@ export function renderXlsxViewer(area, data) {
menu.appendChild(b);
};
item(t("xlsx.sheet_add"), async () => {
const name = prompt(t("xlsx.structure_prompt_add"));
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_rename"), async () => {
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
const to = await showPrompt({
title: t("xlsx.sheet_rename"),
message: t("xlsx.structure_prompt_rename"),
value: sheetName,
});
if (!to || to === sheetName) return;
const actions = [{ op: "sheet_rename", from: sheetName, to }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_duplicate"), async () => {
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
const as = await showPrompt({
title: t("xlsx.sheet_duplicate"),
message: t("xlsx.structure_prompt_add"),
value: `${sheetName} (copie)`,
});
if (!as) return;
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_delete"), async () => {
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
const okDelete = await showConfirm({
title: t("xlsx.sheet_delete"),
message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }),
confirmLabel: t("xlsx.sheet_delete"),
danger: true,
});
if (!okDelete) return;
const actions = [{ op: "sheet_delete", name: sheetName }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
@@ -1850,7 +1917,13 @@ export function renderXlsxViewer(area, data) {
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.row_delete"), async () => {
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
const okRow = await showConfirm({
title: t("xlsx.row_delete"),
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
confirmLabel: t("xlsx.row_delete"),
danger: true,
});
if (!okRow) return;
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
@@ -1859,7 +1932,13 @@ export function renderXlsxViewer(area, data) {
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.col_delete"), async () => {
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
const okCol = await showConfirm({
title: t("xlsx.col_delete"),
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
confirmLabel: t("xlsx.col_delete"),
danger: true,
});
if (!okCol) return;
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
+3
View File
@@ -1827,6 +1827,9 @@
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
"xlsx.lossy_cancelled": "Save cancelled",
"xlsx.lossy_confirm_btn": "Save anyway",
"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.cached_value_title": "Last value calculated by Excel",
"xlsx.tabs_add_sheet": "Add a sheet",
+3
View File
@@ -1827,6 +1827,9 @@
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
"xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.lossy_confirm_btn": "Enregistrer quand même",
"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.cached_value_title": "Dernière valeur calculée par Excel",
"xlsx.tabs_add_sheet": "Ajouter une feuille",
+49
View File
@@ -11051,6 +11051,55 @@ body.desktop-mode .editor-container {
.xlsx-status-formula {
color: var(--accent);
}
/* #154-A2 — unsaved-change feedback: the primary button and the owning tab. */
.xlsx-save-primary.is-dirty {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.xlsx-tab-dirty::after {
content: "•";
margin-left: 5px;
color: var(--warning, #e0a800);
}
/* #154-A2 — non-blocking banners (conflict, …). */
.xlsx-banner-host {
display: flex;
flex-direction: column;
gap: 8px;
}
.xlsx-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
font-size: 0.82rem;
}
.xlsx-banner-conflict {
border-left-color: var(--warning, #e0a800);
}
.xlsx-banner-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: var(--warning, #e0a800);
}
.xlsx-banner-text {
flex: 1;
min-width: 0;
}
.xlsx-banner-dismiss {
padding: 2px 8px;
}
.modal-confirm-text {
color: var(--text-primary);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-line;
}
.xlsx-table {
background: var(--grid-bg);
}