feat: menu contextuel et selection type Excel dans la grille XLSX #155
This commit is contained in:
+288
-19
@@ -19,6 +19,7 @@ import { openAssistant } from './ai-fab.js';
|
||||
import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
|
||||
import { buildCommandBar } from './xlsx/command-bar.js';
|
||||
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
|
||||
import { openContextMenu, closeContextMenu } from './xlsx/context-menu.js';
|
||||
|
||||
// ── Multi-format export ────────────────────────────────────────────────────
|
||||
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
|
||||
@@ -1193,6 +1194,9 @@ export function renderXlsxViewer(area, data) {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// A re-render invalidates any open context menu (it targets the old DOM).
|
||||
closeContextMenu();
|
||||
|
||||
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
|
||||
// Fetched lazily on first open of the dashboard tab; a workbook without
|
||||
// anything notable hides the button entirely (no empty panel).
|
||||
@@ -1420,9 +1424,67 @@ export function renderXlsxViewer(area, data) {
|
||||
const activeCellEl = area.querySelector("#xlsx-active-cell");
|
||||
const formulaInput = area.querySelector("#xlsx-formula-input");
|
||||
const syncing = { value: false }; // guard against input-event feedback loops
|
||||
// #155 — Excel-like range selection: { panel, anchor, focus } in (row, col).
|
||||
let selection = null;
|
||||
let suppressSelectionReset = false;
|
||||
|
||||
const cellName = (td) => td?.dataset.cell || "";
|
||||
|
||||
const clearSelectionClasses = () => {
|
||||
const host = selection && selection.panel ? selection.panel : area;
|
||||
host.querySelectorAll("td.xlsx-selected").forEach((c) => c.classList.remove("xlsx-selected"));
|
||||
};
|
||||
|
||||
const selectionBounds = () => {
|
||||
if (!selection) return null;
|
||||
const { anchor, focus } = selection;
|
||||
return {
|
||||
r1: Math.min(anchor.row, focus.row),
|
||||
r2: Math.max(anchor.row, focus.row),
|
||||
c1: Math.min(anchor.col, focus.col),
|
||||
c2: Math.max(anchor.col, focus.col),
|
||||
};
|
||||
};
|
||||
|
||||
const rangeLabel = () => {
|
||||
const b = selectionBounds();
|
||||
if (!b) return "";
|
||||
const first = `${columnName(b.c1)}${b.r1}`;
|
||||
return b.r1 === b.r2 && b.c1 === b.c2 ? first : `${first}:${columnName(b.c2)}${b.r2}`;
|
||||
};
|
||||
|
||||
// Paint the current selection and show its address in the Name Box.
|
||||
const applySelection = () => {
|
||||
if (!selection) return;
|
||||
const { panel } = selection;
|
||||
const b = selectionBounds();
|
||||
panel.querySelectorAll("td.xlsx-selected").forEach((c) => c.classList.remove("xlsx-selected"));
|
||||
for (let r = b.r1; r <= b.r2; r++) {
|
||||
for (let c = b.c1; c <= b.c2; c++) {
|
||||
const td = findTd(panel, r, c);
|
||||
if (td) td.classList.add("xlsx-selected");
|
||||
}
|
||||
}
|
||||
activeCellEl.textContent = rangeLabel();
|
||||
};
|
||||
|
||||
const setSelection = (anchorTd, focusTd = anchorTd) => {
|
||||
if (!anchorTd) { clearSelectionClasses(); selection = null; return; }
|
||||
const panel = anchorTd.closest(".xlsx-panel");
|
||||
const anchor = parseRef(cellName(anchorTd));
|
||||
const focus = parseRef(cellName(focusTd));
|
||||
if (!panel || !anchor || !focus) return;
|
||||
selection = { panel, anchor, focus };
|
||||
applySelection();
|
||||
};
|
||||
|
||||
const inSelection = (td) => {
|
||||
const b = selectionBounds();
|
||||
const p = parseRef(cellName(td));
|
||||
if (!b || !p || !selection || selection.panel !== td.closest(".xlsx-panel")) return false;
|
||||
return p.row >= b.r1 && p.row <= b.r2 && p.col >= b.c1 && p.col <= b.c2;
|
||||
};
|
||||
|
||||
const setActiveCell = (td) => {
|
||||
activeTd = td || null;
|
||||
if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
|
||||
@@ -1430,12 +1492,14 @@ export function renderXlsxViewer(area, data) {
|
||||
activeCellEl.textContent = "—";
|
||||
formulaInput.value = "";
|
||||
formulaInput.disabled = true;
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
td.classList.add("xlsx-active");
|
||||
activeCellEl.textContent = cellName(td);
|
||||
formulaInput.disabled = false;
|
||||
formulaInput.value = td.textContent;
|
||||
setSelection(td);
|
||||
};
|
||||
|
||||
const syncFormulaBar = (td) => {
|
||||
@@ -1463,7 +1527,7 @@ export function renderXlsxViewer(area, data) {
|
||||
|
||||
// #154-A5 — parseRef / columnName / findTd live in frontend/js/xlsx/refs.js.
|
||||
|
||||
const moveActive = (td, key, forward = true) => {
|
||||
const moveActive = (td, key, forward = true, extend = false) => {
|
||||
const ref = parseRef(cellName(td));
|
||||
if (!ref) return;
|
||||
let { row, col } = ref;
|
||||
@@ -1476,6 +1540,17 @@ export function renderXlsxViewer(area, data) {
|
||||
const panel = td.closest(".xlsx-panel");
|
||||
const next = findTd(panel, row, col);
|
||||
if (!next) return; // edge of the rendered window: no wrap
|
||||
// #155 — Shift+arrow extends the selection from the current anchor.
|
||||
if (extend && selection && selection.panel === panel) {
|
||||
suppressSelectionReset = true;
|
||||
next.focus();
|
||||
suppressSelectionReset = false;
|
||||
selection.focus = { row, col };
|
||||
applySelection();
|
||||
activeTd = next;
|
||||
formulaInput.value = next.textContent;
|
||||
return;
|
||||
}
|
||||
td.blur();
|
||||
next.focus();
|
||||
setActiveCell(next);
|
||||
@@ -1577,7 +1652,18 @@ export function renderXlsxViewer(area, data) {
|
||||
syncFormulaBar(td);
|
||||
refreshSaveState();
|
||||
});
|
||||
td.addEventListener("focus", () => { beginEdit(td); setActiveCell(td); });
|
||||
td.addEventListener("focus", () => {
|
||||
beginEdit(td);
|
||||
// #155 — a range extension keeps the selection; only the bar follows.
|
||||
if (suppressSelectionReset) {
|
||||
activeTd = td;
|
||||
activeCellEl.textContent = cellName(td);
|
||||
formulaInput.disabled = false;
|
||||
formulaInput.value = td.textContent;
|
||||
return;
|
||||
}
|
||||
setActiveCell(td);
|
||||
});
|
||||
td.addEventListener("blur", commitEdit);
|
||||
td.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
||||
@@ -1597,7 +1683,7 @@ export function renderXlsxViewer(area, data) {
|
||||
const forward = e.key === "Tab"
|
||||
? !e.shiftKey
|
||||
: (e.key === "ArrowRight" || e.key === "ArrowDown");
|
||||
moveActive(td, e.key, forward);
|
||||
moveActive(td, e.key, forward, e.shiftKey && e.key.startsWith("Arrow"));
|
||||
}
|
||||
});
|
||||
td.addEventListener("paste", (e) => {
|
||||
@@ -1762,22 +1848,38 @@ export function renderXlsxViewer(area, data) {
|
||||
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) => {
|
||||
// #155 — Excel-like: clicking a column/row header selects the whole
|
||||
// column/row. Sorting moved to the context menu (and the sort reset button).
|
||||
const selectColumn = (panel, col) => {
|
||||
const rows = panel.querySelectorAll("th.xlsx-rownum").length;
|
||||
if (!rows || !col) return;
|
||||
selection = { panel, anchor: { row: 1, col }, focus: { row: rows, col } };
|
||||
applySelection();
|
||||
const td = findTd(panel, 1, col);
|
||||
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = td.textContent; }
|
||||
};
|
||||
const selectRow = (panel, row) => {
|
||||
const cols = panel.querySelectorAll(".xlsx-table thead th:not(.xlsx-corner)").length;
|
||||
if (!row || !cols) return;
|
||||
selection = { panel, anchor: { row, col: 1 }, focus: { row, col: cols } };
|
||||
applySelection();
|
||||
const td = findTd(panel, row, 1);
|
||||
if (td) { activeTd = td; formulaInput.disabled = false; formulaInput.value = td.textContent; }
|
||||
};
|
||||
|
||||
const wireHeaderSelection = (panel) => {
|
||||
const table = panel.querySelector(".xlsx-table");
|
||||
if (!table || table.dataset.selectWired) return;
|
||||
table.dataset.selectWired = "1";
|
||||
table.querySelectorAll("thead 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");
|
||||
});
|
||||
th.title = t("xlsx.select_column");
|
||||
th.addEventListener("click", () => selectColumn(panel, [...th.parentElement.children].indexOf(th)));
|
||||
});
|
||||
table.querySelectorAll("th.xlsx-rownum").forEach((th) => {
|
||||
th.style.cursor = "pointer";
|
||||
th.title = t("xlsx.select_row");
|
||||
th.addEventListener("click", () => selectRow(panel, Number(th.textContent)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1791,7 +1893,7 @@ export function renderXlsxViewer(area, data) {
|
||||
if (n) sortResetBtn.style.display = "";
|
||||
};
|
||||
|
||||
panelEls.forEach(wireHeaderSort);
|
||||
panelEls.forEach(wireHeaderSelection);
|
||||
|
||||
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
|
||||
sortResetBtn.addEventListener("click", () => {
|
||||
@@ -2055,6 +2157,173 @@ export function renderXlsxViewer(area, data) {
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
// ══ #155 — Excel-like selection & context menu ═════════════════════════
|
||||
const viewerEl = area.querySelector(".xlsx-viewer");
|
||||
|
||||
const contextTargetFrom = (target) => {
|
||||
if (!target || !target.closest) return null;
|
||||
const panel = target.closest(".xlsx-panel");
|
||||
if (!panel) return null;
|
||||
const rownum = target.closest("th.xlsx-rownum");
|
||||
if (rownum) return { panel, kind: "row", row: Number(rownum.textContent) };
|
||||
const header = target.closest(".xlsx-table thead th:not(.xlsx-corner)");
|
||||
if (header) return { panel, kind: "col", col: [...header.parentElement.children].indexOf(header) };
|
||||
const td = target.closest("td[data-cell]");
|
||||
if (td) return { panel, kind: "cell", td };
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectForTarget = (target) => {
|
||||
if (target.kind === "row") selectRow(target.panel, target.row);
|
||||
else if (target.kind === "col") selectColumn(target.panel, target.col);
|
||||
else if (target.kind === "cell"
|
||||
&& (!selection || selection.panel !== target.panel || !inSelection(target.td))) {
|
||||
setActiveCell(target.td);
|
||||
}
|
||||
};
|
||||
|
||||
// One structure write (insert/delete) with the usual lossy/error handling;
|
||||
// destructive actions ask for confirmation first.
|
||||
const structureAction = (action, confirmMsg) => {
|
||||
const run = async () => {
|
||||
try { await putStructure([action]); } catch (err) { structureError(err, [action]); }
|
||||
};
|
||||
if (confirmMsg) {
|
||||
showConfirm({
|
||||
title: t("xlsx.structure_title"),
|
||||
message: confirmMsg,
|
||||
confirmLabel: t("common.confirm"),
|
||||
danger: true,
|
||||
}).then((ok) => { if (ok) run(); });
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
};
|
||||
|
||||
const clearContents = (panel, b) => {
|
||||
for (let r = b.r1; r <= b.r2; r++) {
|
||||
for (let c = b.c1; c <= b.c2; c++) {
|
||||
const td = findTd(panel, r, c);
|
||||
if (td && td.textContent !== "") { td.textContent = ""; td.classList.add("xlsx-dirty"); }
|
||||
}
|
||||
}
|
||||
refreshSaveState();
|
||||
};
|
||||
|
||||
const buildContextItems = (target) => {
|
||||
const panel = target.panel;
|
||||
const sheetName = sheets[Number(panel.dataset.sheet)]?.name || "";
|
||||
const b = selectionBounds();
|
||||
if (!b) return [];
|
||||
const rowCount = b.r2 - b.r1 + 1;
|
||||
const colCount = b.c2 - b.c1 + 1;
|
||||
const items = [];
|
||||
if (editable) {
|
||||
items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
|
||||
items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
|
||||
items.push({ label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
|
||||
items.push({ label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
|
||||
items.push({ separator: true });
|
||||
items.push({ label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
|
||||
items.push({ label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
|
||||
items.push({ separator: true });
|
||||
}
|
||||
if (!isCsv) {
|
||||
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
|
||||
items.push({ label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
|
||||
}
|
||||
if (editable) {
|
||||
items.push({ separator: true });
|
||||
items.push({ label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const openGridMenu = (x, y, target) => {
|
||||
selectForTarget(target);
|
||||
openContextMenu(x, y, buildContextItems(target));
|
||||
};
|
||||
|
||||
viewerEl.addEventListener("contextmenu", (e) => {
|
||||
const target = contextTargetFrom(e.target);
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
openGridMenu(e.clientX, e.clientY, target);
|
||||
});
|
||||
|
||||
// Long-press (touch) — the mobile equivalent of a right-click.
|
||||
let longPressTimer = null;
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||||
};
|
||||
viewerEl.addEventListener("touchstart", (e) => {
|
||||
const target = contextTargetFrom(e.target);
|
||||
if (!target || !e.touches || !e.touches[0]) return;
|
||||
const t0 = e.touches[0];
|
||||
cancelLongPress();
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
openGridMenu(t0.clientX, t0.clientY, target);
|
||||
}, 500);
|
||||
}, { passive: true });
|
||||
viewerEl.addEventListener("touchmove", cancelLongPress, { passive: true });
|
||||
viewerEl.addEventListener("touchend", cancelLongPress);
|
||||
viewerEl.addEventListener("touchcancel", cancelLongPress);
|
||||
|
||||
// Drag-to-select a rectangular range + Shift+click extension.
|
||||
let dragAnchorTd = null;
|
||||
let dragActive = false;
|
||||
let dragExtended = false;
|
||||
viewerEl.addEventListener("mousedown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const td = e.target.closest && e.target.closest("td[data-cell]");
|
||||
if (!td) return;
|
||||
if (e.shiftKey && selection && selection.panel === td.closest(".xlsx-panel")) {
|
||||
e.preventDefault();
|
||||
selection.focus = parseRef(cellName(td)) || selection.focus;
|
||||
applySelection();
|
||||
activeTd = td;
|
||||
formulaInput.disabled = false;
|
||||
formulaInput.value = td.textContent;
|
||||
return;
|
||||
}
|
||||
dragAnchorTd = td;
|
||||
dragActive = true;
|
||||
dragExtended = false;
|
||||
});
|
||||
viewerEl.addEventListener("mouseover", (e) => {
|
||||
if (!dragActive || !dragAnchorTd) return;
|
||||
const td = e.target.closest && e.target.closest("td[data-cell]");
|
||||
if (!td || td === dragAnchorTd) return;
|
||||
const panel = dragAnchorTd.closest(".xlsx-panel");
|
||||
if (td.closest(".xlsx-panel") !== panel) return;
|
||||
const a = parseRef(cellName(dragAnchorTd));
|
||||
const f = parseRef(cellName(td));
|
||||
if (!a || !f) return;
|
||||
if (!dragExtended) {
|
||||
dragExtended = true;
|
||||
// Stop editing the anchor cell and drop any in-cell text selection.
|
||||
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
||||
if (document.getSelection) { const s = document.getSelection(); if (s && s.removeAllRanges) s.removeAllRanges(); }
|
||||
viewerEl.classList.add("xlsx-selecting");
|
||||
}
|
||||
selection = { panel, anchor: a, focus: f };
|
||||
applySelection();
|
||||
});
|
||||
const endDrag = () => {
|
||||
if (dragActive && dragExtended && dragAnchorTd) {
|
||||
activeTd = dragAnchorTd;
|
||||
formulaInput.disabled = false;
|
||||
formulaInput.value = dragAnchorTd.textContent;
|
||||
}
|
||||
dragActive = false;
|
||||
dragExtended = false;
|
||||
dragAnchorTd = null;
|
||||
viewerEl.classList.remove("xlsx-selecting");
|
||||
};
|
||||
viewerEl.addEventListener("mouseup", endDrag);
|
||||
viewerEl.addEventListener("mouseleave", endDrag);
|
||||
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
|
||||
*
|
||||
* Builds a small menu anchored at a screen position, closes it on outside
|
||||
* click / Escape / window blur. Labels and callbacks come from the caller, so
|
||||
* no i18n lives here.
|
||||
*/
|
||||
let _menu = null;
|
||||
let _openedAt = 0;
|
||||
let _dismissBound = false;
|
||||
|
||||
export function closeContextMenu() {
|
||||
if (_menu) {
|
||||
_menu.remove();
|
||||
_menu = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isContextMenuOpen() {
|
||||
return _menu !== null;
|
||||
}
|
||||
|
||||
function bindDismiss() {
|
||||
if (_dismissBound) return;
|
||||
_dismissBound = true;
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!_menu) return;
|
||||
// Ignore the very gesture that opened the menu (touch long-press can emit
|
||||
// a click right after).
|
||||
if (Date.now() - _openedAt < 250) return;
|
||||
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
|
||||
closeContextMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeContextMenu();
|
||||
});
|
||||
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x - viewport X of the pointer
|
||||
* @param {number} y - viewport Y of the pointer
|
||||
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @returns {HTMLElement} the menu element
|
||||
*/
|
||||
export function openContextMenu(x, y, items) {
|
||||
bindDismiss();
|
||||
closeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "xlsx-context-menu";
|
||||
menu.setAttribute("role", "menu");
|
||||
for (const item of items) {
|
||||
if (item.separator) {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "xlsx-context-sep";
|
||||
menu.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
|
||||
btn.setAttribute("role", "menuitem");
|
||||
btn.textContent = item.label;
|
||||
btn.disabled = Boolean(item.disabled);
|
||||
btn.addEventListener("click", () => {
|
||||
closeContextMenu();
|
||||
if (!item.disabled && item.onClick) item.onClick();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
|
||||
// which simply leaves the requested position).
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const vw = (typeof window !== "undefined" && window.innerWidth) || 1024;
|
||||
const vh = (typeof window !== "undefined" && window.innerHeight) || 768;
|
||||
const left = Math.max(4, Math.min(x, vw - rect.width - 4));
|
||||
const top = Math.max(4, Math.min(y, vh - rect.height - 4));
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
|
||||
_menu = menu;
|
||||
_openedAt = Date.now();
|
||||
return menu;
|
||||
}
|
||||
@@ -1875,6 +1875,17 @@
|
||||
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
|
||||
"xlsx.select_column": "Select the column",
|
||||
"xlsx.select_row": "Select the row",
|
||||
"xlsx.insert_row_above": "Insert a row above",
|
||||
"xlsx.insert_row_below": "Insert a row below",
|
||||
"xlsx.insert_col_left": "Insert a column to the left",
|
||||
"xlsx.insert_col_right": "Insert a column to the right",
|
||||
"xlsx.delete_rows": "Delete {n} row(s)",
|
||||
"xlsx.delete_cols": "Delete {n} column(s)",
|
||||
"xlsx.clear_contents": "Clear contents",
|
||||
"xlsx.confirm_delete_rows": "Delete {n} row(s)? This changes the file (a backup is created).",
|
||||
"xlsx.confirm_delete_cols": "Delete {n} column(s)? This changes the file (a backup is created).",
|
||||
"xlsx.structure_saved": "Structure updated",
|
||||
"xlsx.structure_error": "Could not change the structure",
|
||||
"xlsx.last_sheet": "The last sheet cannot be deleted",
|
||||
|
||||
@@ -1875,6 +1875,17 @@
|
||||
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.select_column": "Sélectionner la colonne",
|
||||
"xlsx.select_row": "Sélectionner la ligne",
|
||||
"xlsx.insert_row_above": "Insérer une ligne au-dessus",
|
||||
"xlsx.insert_row_below": "Insérer une ligne en dessous",
|
||||
"xlsx.insert_col_left": "Insérer une colonne à gauche",
|
||||
"xlsx.insert_col_right": "Insérer une colonne à droite",
|
||||
"xlsx.delete_rows": "Supprimer {n} ligne(s)",
|
||||
"xlsx.delete_cols": "Supprimer {n} colonne(s)",
|
||||
"xlsx.clear_contents": "Effacer le contenu",
|
||||
"xlsx.confirm_delete_rows": "Supprimer {n} ligne(s) ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.confirm_delete_cols": "Supprimer {n} colonne(s) ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_saved": "Structure mise à jour",
|
||||
"xlsx.structure_error": "Modification de la structure impossible",
|
||||
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
|
||||
|
||||
+51
-1
@@ -11148,7 +11148,8 @@ body.desktop-mode .editor-container {
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
}
|
||||
.xlsx-table td[contenteditable] {
|
||||
cursor: text;
|
||||
/* #155 — Excel-style cross cursor over the cells. */
|
||||
cursor: cell;
|
||||
min-width: 40px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -11473,6 +11474,55 @@ body.desktop-mode .editor-container {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* #155 — Excel-like range selection + right-click / long-press context menu. */
|
||||
.xlsx-table tbody td.xlsx-selected {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
.xlsx-viewer.xlsx-selecting,
|
||||
.xlsx-viewer.xlsx-selecting td {
|
||||
user-select: none;
|
||||
}
|
||||
.xlsx-viewer.xlsx-selecting td[contenteditable] {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.xlsx-context-menu {
|
||||
position: fixed;
|
||||
z-index: 10050;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.xlsx-context-item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-context-item:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.xlsx-context-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.xlsx-context-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.xlsx-context-sep {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* JSDOM shims for the tests that click anchors */
|
||||
mark {
|
||||
font: inherit;
|
||||
|
||||
+2
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v28';
|
||||
const SW_VERSION = 'v29';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
@@ -33,6 +33,7 @@ const PRECACHE_URLS = [
|
||||
'/static/js/xlsx/refs.js',
|
||||
'/static/js/xlsx/command-bar.js',
|
||||
'/static/js/xlsx/dashboard.js',
|
||||
'/static/js/xlsx/context-menu.js',
|
||||
'/static/js/search.js',
|
||||
'/static/js/config.js',
|
||||
'/static/js/utils.js',
|
||||
|
||||
Reference in New Issue
Block a user