feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
Restaure et termine le lot tableur resté non committé. Il n'existait que dans un `git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js, color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le stash, créé avant le commit BUG-108, n'a jamais été restauré. #180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint), grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu Fichier au niveau des onglets, barre épinglée pleine largeur. #181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans quitter contenteditable, double-clic / F2 / première frappe qui remplace, Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts. #182 Désélection fiable (clic simple sans dépendre du focus), contours de plage non empilés (sélecteur de classes sans point), couleurs texte/fond sur une plage, clic droit qui préserve la sélection multiple. #183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD, PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était rejetée par normHex). #184 Peinture de format complète (toutes propriétés, source sans format = réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de cellules `ref → td`, court-circuits formule/styles, marqueur data-wired. #185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36 annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème. #186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC (openpyxl) au lieu d'une charge utile texte illisible. Corrections trouvées en restaurant et en exerçant le lot : - fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ; - sorties Markdown / HTML / Imprimer de la barre de menus inertes (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ; - collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ; - curseur col/row-resize absent quand le pointeur est sur la table elle-même ; - l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown, HTML, impression) ; - clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au seul appui long tactile) ; - une entrée indisponible laissait la barre de menus ouverte. Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ; 33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19, xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer 19/19.
This commit is contained in:
@@ -587,6 +587,26 @@ function applyTheme(themeKey, mode) {
|
||||
Object.keys(vars).forEach(function(key) {
|
||||
root.style.setProperty(key, vars[key]);
|
||||
});
|
||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
||||
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
|
||||
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
||||
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
||||
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||
// cell background on most themes, so the grid was invisible). The line is now
|
||||
// a blend of the text colour into the sheet surface: the SAME single value
|
||||
// for every line, and legible on both dark and light grounds.
|
||||
var _gridSurface = vars['--surface'] || vars['--bg-secondary'] || '#000000';
|
||||
var _gridInk = vars['--text-primary'] || vars['--text'] || '#888888';
|
||||
var _grid = {
|
||||
'--grid-bg': _gridSurface,
|
||||
'--grid-header-bg': vars['--bg-hover'] || _gridSurface,
|
||||
'--grid-header-text': _gridInk,
|
||||
'--grid-border': 'color-mix(in srgb, ' + _gridInk + ' 22%, ' + _gridSurface + ')',
|
||||
'--grid-zebra': 'color-mix(in srgb, ' + _gridInk + ' 4%, transparent)'
|
||||
};
|
||||
Object.keys(_grid).forEach(function(key) {
|
||||
if (_grid[key]) root.style.setProperty(key, _grid[key]);
|
||||
});
|
||||
// Store resolved vars on the theme for export/preview
|
||||
if (!theme.modes[mode]) theme.modes[mode] = vars;
|
||||
|
||||
|
||||
+10
-9
@@ -795,15 +795,16 @@ const FileOperations = {
|
||||
<div class="modal-form-group">
|
||||
<label class="modal-label">${t('modal.file_type')}</label>
|
||||
<select class="modal-select" id="file-ext-select">
|
||||
<option value=".md">Markdown (.md)</option>
|
||||
<option value=".txt">Texte (.txt)</option>
|
||||
<option value=".py">Python (.py)</option>
|
||||
<option value=".js">JavaScript (.js)</option>
|
||||
<option value=".json">JSON (.json)</option>
|
||||
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
|
||||
<option value=".yaml">YAML (.yaml)</option>
|
||||
<option value=".sh">Shell (.sh)</option>
|
||||
<option value=".ps1">PowerShell (.ps1)</option>
|
||||
<option value=".md">${t('modal.ext_md')}</option>
|
||||
<option value=".txt">${t('modal.ext_txt')}</option>
|
||||
<option value=".py">${t('modal.ext_py')}</option>
|
||||
<option value=".js">${t('modal.ext_js')}</option>
|
||||
<option value=".json">${t('modal.ext_json')}</option>
|
||||
<option value=".xlsx">${t('modal.ext_xlsx')}</option>
|
||||
<option value=".excalidraw">${t('modal.ext_excalidraw')}</option>
|
||||
<option value=".yaml">${t('modal.ext_yaml')}</option>
|
||||
<option value=".sh">${t('modal.ext_sh')}</option>
|
||||
<option value=".ps1">${t('modal.ext_ps1')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -695,6 +695,21 @@ async function reloadExternalWrite(vault, path, force = false) {
|
||||
}
|
||||
// Not editing: refresh the read view when it shows the written document.
|
||||
if (state.currentVault === vault && state.currentPath === path) {
|
||||
// The spreadsheet viewer applies its own writes dynamically (values,
|
||||
// styles and structure stay in the DOM): reloading it here would wipe the
|
||||
// grid behind a loading spinner on every save. Skip the reload when the
|
||||
// event is the watcher echo of our own write (recent stamp) or when
|
||||
// unsaved edits are still on screen (they would be clobbered). Forced
|
||||
// reloads (assistant writes) still go through.
|
||||
const xlsxOpen = !force
|
||||
&& document.querySelector("#content-area .xlsx-viewer");
|
||||
if (xlsxOpen) {
|
||||
const own = window.__xlsxLastOwnWrite;
|
||||
const recentOwn = own && own.vault === vault && own.path === path
|
||||
&& (Date.now() - (own.at || 0)) < 15000;
|
||||
const hasDirty = Boolean(document.querySelector("#content-area td.xlsx-dirty"));
|
||||
if (recentOwn || hasDirty) return;
|
||||
}
|
||||
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
|
||||
// refresh) and discard the in-editor scene. Its own save already persisted
|
||||
// the file, so there is nothing to reload here.
|
||||
|
||||
+3629
-185
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
/* ObsiGate — XLSX colour picker panel (#183).
|
||||
*
|
||||
* Replaces the native `<input type="color">` of the spreadsheet ribbon and of
|
||||
* the Mise en forme menu with a Google-Sheets-like panel:
|
||||
*
|
||||
* ┌ header ─ « Réinitialiser » ───────────────────┐
|
||||
* │ palette 8 x 10 : ligne 1 = échelles de gris, │
|
||||
* │ lignes 2-10 = 8 teintes x 9 │
|
||||
* │ nuances (clair -> foncé) │
|
||||
* │ STANDARD 8 pastilles prédéfinies │
|
||||
* │ PERSONNALISÉ pastille + bouton « + » + crayon │
|
||||
* │ pied Mise en forme conditionnelle, │
|
||||
* │ Couleurs en alternance │
|
||||
* └────────────────────────────────────────────────┘
|
||||
*
|
||||
* Picking a colour closes the panel and applies it (the caller owns the
|
||||
* write). The panel markup is built here, positioning and dismissal are the
|
||||
* viewer's (it already owns `anchorUnder` + `trackDismissable` for the other
|
||||
* spreadsheet menus), so this module stays a pure builder.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
// 8 columns = 8 hues, in the Google Sheets order: red, orange, yellow, light
|
||||
// green, cyan, blue, violet, magenta.
|
||||
const HUES = [0, 30, 55, 100, 180, 215, 270, 320];
|
||||
// 9 value rows, light (top) -> dark (bottom).
|
||||
const ROWS = 9;
|
||||
const GREYS = 8;
|
||||
|
||||
/**
|
||||
* HSL → `#rrggbb`.
|
||||
*
|
||||
* The palette is generated in HSL for readability, but it MUST leave this
|
||||
* module as hex: the write path (`normHex` in viewer.js) and the backend only
|
||||
* accept `#rrggbb`, so an `hsl(...)` swatch would be silently dropped on save.
|
||||
*/
|
||||
export function hslToHex(h, s, l) {
|
||||
const hue = ((h % 360) + 360) % 360;
|
||||
const sat = Math.min(1, Math.max(0, s));
|
||||
const lig = Math.min(1, Math.max(0, l));
|
||||
const c = (1 - Math.abs(2 * lig - 1)) * sat;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = lig - c / 2;
|
||||
const seg = Math.floor(hue / 60) % 6;
|
||||
const rgb = [
|
||||
[c, x, 0], [x, c, 0], [0, c, x],
|
||||
[0, x, c], [x, 0, c], [c, 0, x],
|
||||
][seg] || [0, 0, 0];
|
||||
return `#${rgb.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
/** Relative luminance (WCAG-ish) of a `#rrggbb` string, 0 (black) → 1 (white). */
|
||||
export function luminanceOf(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "").trim());
|
||||
if (!m) return 0;
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = ((n >> 16) & 255) / 255;
|
||||
const g = ((n >> 8) & 255) / 255;
|
||||
const b = (n & 255) / 255;
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 80 swatches: a first row of greys then 9 x 8 hues.
|
||||
* @returns {Array<Array<string>>} 10 rows of 8 CSS colours
|
||||
*/
|
||||
export function buildPalette() {
|
||||
const rows = [];
|
||||
const greys = [];
|
||||
for (let i = 0; i < GREYS; i++) {
|
||||
// black -> white in 8 steps (l stays a 0..1 ratio, converted by hslToHex)
|
||||
greys.push(hslToHex(0, 0, i / (GREYS - 1)));
|
||||
}
|
||||
rows.push(greys);
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const l = 0.90 - r * (0.60 / (ROWS - 1)); // 0.90 -> 0.30
|
||||
const s = 0.60 + r * (0.25 / (ROWS - 1)); // 0.60 -> 0.85
|
||||
rows.push(HUES.map((h) => hslToHex(h, s, l)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The 8 predefined colours of the STANDARD row. */
|
||||
export const STANDARD_COLOURS = [
|
||||
{ key: "black", hex: "#000000" },
|
||||
{ key: "white", hex: "#ffffff" },
|
||||
{ key: "blue", hex: "#1a73e8" },
|
||||
{ key: "red", hex: "#d93025" },
|
||||
{ key: "yellow", hex: "#f4b400" },
|
||||
{ key: "green", hex: "#188038" },
|
||||
{ key: "orange", hex: "#e8710a" },
|
||||
{ key: "cyan", hex: "#00bcd4" },
|
||||
];
|
||||
|
||||
const CHECK_SVG =
|
||||
'<svg class="xlsx-colour-check" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true" focusable="false">'
|
||||
+ '<path fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/></svg>';
|
||||
|
||||
const icon = (name) =>
|
||||
`<i data-lucide="${name}" class="xlsx-colour-icon" aria-hidden="true"></i>`;
|
||||
|
||||
const button = (cls, label, ariaLabel) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `btn-action ${cls}`;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", ariaLabel || label);
|
||||
return b;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single round swatch.
|
||||
* @param {string} colour - CSS colour
|
||||
* @param {string} label - tooltip / aria-label
|
||||
* @param {boolean} selected - draw the check mark
|
||||
*/
|
||||
const swatchButton = (colour, label, selected) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "xlsx-colour-swatch-btn";
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.style.background = colour;
|
||||
b.dataset.colour = colour;
|
||||
if (selected) {
|
||||
b.classList.add("selected");
|
||||
b.setAttribute("aria-pressed", "true");
|
||||
// White check on dark grounds, black on light ones — otherwise the mark
|
||||
// disappears (the same contrast rule as the design).
|
||||
b.style.color = luminanceOf(colour) > 0.55 ? "#000000" : "#ffffff";
|
||||
b.innerHTML = CHECK_SVG;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the panel.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {"font"|"fill"} opts.kind - drives the panel label and the footer
|
||||
* @param {string} [opts.value] - current colour (`""` = none)
|
||||
* @param {string} [opts.custom] - colour shown in the PERSONNALISÉ slot
|
||||
* @param {Function} opts.onPick - (cssColour) => void; the panel is closed by
|
||||
* the caller right after
|
||||
* @param {Function} [opts.onReset] - clear the colour entirely
|
||||
* @param {Function} [opts.onCustom] - open the native picker for a custom
|
||||
* colour, called with no argument
|
||||
* @param {Function} [opts.onUnavailable] - (i18nLabel) => void for the footer
|
||||
* entries that ObsiGate does not implement yet
|
||||
* @returns {{ el: HTMLElement, sync: Function }}
|
||||
*/
|
||||
export function createColourPicker({
|
||||
kind = "font",
|
||||
value = "",
|
||||
custom = "",
|
||||
onPick,
|
||||
onReset,
|
||||
onCustom,
|
||||
onUnavailable,
|
||||
} = {}) {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "xlsx-colour-panel";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute(
|
||||
"aria-label",
|
||||
kind === "fill" ? t("xlsx.colour_panel_fill") : t("xlsx.colour_panel_font")
|
||||
);
|
||||
const isFill = kind === "fill";
|
||||
const norm = (c) => String(c || "").trim().toLowerCase();
|
||||
|
||||
// ── Header : Réinitialiser ────────────────────────────────────────────────
|
||||
const head = document.createElement("div");
|
||||
head.className = "xlsx-colour-head";
|
||||
const reset = button(
|
||||
"xlsx-colour-reset",
|
||||
t("xlsx.colour_reset"),
|
||||
isFill ? t("xlsx.colour_reset_fill") : t("xlsx.colour_reset_font")
|
||||
);
|
||||
reset.innerHTML = `${icon(isFill ? "eraser" : "paintbrush")}<span>${t("xlsx.colour_reset")}</span>`;
|
||||
reset.addEventListener("click", () => {
|
||||
if (onReset) onReset();
|
||||
});
|
||||
head.appendChild(reset);
|
||||
panel.appendChild(head);
|
||||
|
||||
// ── Palette 8 x 10 ───────────────────────────────────────────────────────
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "xlsx-colour-grid";
|
||||
grid.setAttribute("role", "group");
|
||||
grid.setAttribute("aria-label", t("xlsx.colour_palette"));
|
||||
const current = norm(value);
|
||||
buildPalette().forEach((row) => {
|
||||
row.forEach((colour) => {
|
||||
const b = swatchButton(colour, colour, norm(colour) === current);
|
||||
b.addEventListener("click", () => {
|
||||
if (onPick) onPick(colour);
|
||||
});
|
||||
grid.appendChild(b);
|
||||
});
|
||||
});
|
||||
panel.appendChild(grid);
|
||||
|
||||
// ── STANDARD ─────────────────────────────────────────────────────────────
|
||||
const standard = document.createElement("div");
|
||||
standard.className = "xlsx-colour-section";
|
||||
const stdHead = document.createElement("div");
|
||||
stdHead.className = "xlsx-colour-title";
|
||||
stdHead.appendChild(document.createTextNode(t("xlsx.colour_standard")));
|
||||
const stdEdit = button(
|
||||
"xlsx-colour-edit",
|
||||
t("xlsx.colour_edit_standard"),
|
||||
t("xlsx.colour_edit_standard")
|
||||
);
|
||||
stdEdit.innerHTML = icon("pencil");
|
||||
stdEdit.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
stdHead.appendChild(stdEdit);
|
||||
standard.appendChild(stdHead);
|
||||
|
||||
const stdRow = document.createElement("div");
|
||||
stdRow.className = "xlsx-colour-row";
|
||||
STANDARD_COLOURS.forEach(({ key, hex }) => {
|
||||
const label = t(`xlsx.colour_${key}`);
|
||||
const b = swatchButton(hex, label, norm(hex) === current);
|
||||
b.addEventListener("click", () => {
|
||||
if (onPick) onPick(hex);
|
||||
});
|
||||
stdRow.appendChild(b);
|
||||
});
|
||||
standard.appendChild(stdRow);
|
||||
panel.appendChild(standard);
|
||||
|
||||
// ── PERSONNALISÉ ─────────────────────────────────────────────────────────
|
||||
const customSection = document.createElement("div");
|
||||
customSection.className = "xlsx-colour-section";
|
||||
const cusHead = document.createElement("div");
|
||||
cusHead.className = "xlsx-colour-title";
|
||||
cusHead.appendChild(document.createTextNode(t("xlsx.colour_custom")));
|
||||
customSection.appendChild(cusHead);
|
||||
|
||||
const cusRow = document.createElement("div");
|
||||
cusRow.className = "xlsx-colour-row";
|
||||
const customColour = custom || value;
|
||||
const cusSwatch = swatchButton(
|
||||
customColour || "transparent",
|
||||
t("xlsx.colour_custom"),
|
||||
Boolean(customColour) && norm(customColour) === current
|
||||
);
|
||||
cusSwatch.addEventListener("click", () => {
|
||||
if (customColour && onPick) onPick(customColour);
|
||||
});
|
||||
cusRow.appendChild(cusSwatch);
|
||||
|
||||
const add = button("xlsx-colour-add", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
|
||||
add.innerHTML = icon("plus");
|
||||
add.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
cusRow.appendChild(add);
|
||||
|
||||
const cusEdit = button("xlsx-colour-edit", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
|
||||
cusEdit.innerHTML = icon("pencil");
|
||||
cusEdit.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
cusRow.appendChild(cusEdit);
|
||||
customSection.appendChild(cusRow);
|
||||
panel.appendChild(customSection);
|
||||
|
||||
// ── Footer ───────────────────────────────────────────────────────────────
|
||||
// Both entries are announced by ObsiGate but not implemented yet: they call
|
||||
// back with their label so the caller can tell the user instead of leaving a
|
||||
// dead button. « Couleurs en alternance » only shows once a colour is set,
|
||||
// exactly like the reference screenshots.
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "xlsx-colour-foot";
|
||||
const footEntry = (label, key) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "xlsx-colour-foot-item";
|
||||
b.textContent = label;
|
||||
b.dataset.colourAction = key;
|
||||
b.addEventListener("click", () => {
|
||||
if (onUnavailable) onUnavailable(label);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
foot.appendChild(footEntry(t("xlsx.colour_conditional"), "conditional"));
|
||||
if (value) foot.appendChild(footEntry(t("xlsx.colour_banded"), "banded"));
|
||||
panel.appendChild(foot);
|
||||
|
||||
/** Repaint the check mark after the value changed (custom colour applied). */
|
||||
const sync = (next, nextCustom) => {
|
||||
const want = norm(next);
|
||||
panel.querySelectorAll(".xlsx-colour-swatch-btn").forEach((b) => {
|
||||
const on = norm(b.dataset.colour) === want;
|
||||
b.classList.toggle("selected", on);
|
||||
b.setAttribute("aria-pressed", String(on));
|
||||
b.innerHTML = on
|
||||
? (b.style.color = luminanceOf(b.dataset.colour) > 0.55 ? "#000000" : "#ffffff", CHECK_SVG)
|
||||
: "";
|
||||
});
|
||||
if (nextCustom) {
|
||||
const slot = panel.querySelector(".xlsx-colour-section:nth-of-type(3) .xlsx-colour-row .xlsx-colour-swatch-btn");
|
||||
if (slot) slot.style.background = nextCustom;
|
||||
}
|
||||
};
|
||||
|
||||
return { el: panel, sync };
|
||||
}
|
||||
+128
-25
@@ -1,19 +1,29 @@
|
||||
/* ObsiGate — XLSX command bar builder (#154-A5).
|
||||
/* ObsiGate — XLSX command bar builder (Sheets-like ribbon).
|
||||
*
|
||||
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
|
||||
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
|
||||
* is testable and the viewer only wires behaviour.
|
||||
*
|
||||
* Order follows Google Sheets: search, undo/redo, print, paint, zoom, number
|
||||
* formats, font size, B/I/S/U, colours, borders, merge, alignments, wrap,
|
||||
* rotation, overflow menu — then the ObsiGate-specific groups (structure,
|
||||
* format, view, gridlines) kept with their stable ids. File outputs
|
||||
* (Download / CSV / Markdown / HTML / Print) moved to the **Fichier** menu of
|
||||
* the menu bar (#185, frontend/js/xlsx/menus.js); only Save stays on the
|
||||
* sheet-tab strip, pinned at its right end.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
const IC = (name) => `<i data-lucide="${name}" style="width:14px;height:14px" aria-hidden="true"></i>`;
|
||||
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {Array<{name: string}>} ctx.sheets
|
||||
* @param {boolean} ctx.isCsv
|
||||
* @param {boolean} ctx.readOnly
|
||||
* @param {boolean} ctx.editable - not a CSV and not read-only
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string, fileHtml: string }}
|
||||
*/
|
||||
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
||||
@@ -26,19 +36,23 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}"><i data-lucide="plus" style="width:14px;height:14px" aria-hidden="true"></i></button>`
|
||||
: ""}</div>`;
|
||||
|
||||
// Status pills make the viewer's limits visible up front.
|
||||
// Status pills make the viewer's limits visible up front (#180: the
|
||||
// "formulas computed locally" pill is gone — local computation is the
|
||||
// normal behaviour, not a limit worth a badge).
|
||||
const statusPills = [
|
||||
readOnly
|
||||
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
|
||||
: "",
|
||||
!isCsv
|
||||
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
|
||||
: "",
|
||||
].filter(Boolean).join("");
|
||||
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
|
||||
|
||||
// Command groups, separated by thin rules.
|
||||
// Command groups, separated by thin rules (Sheets order).
|
||||
const actionGroups = [];
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="search">
|
||||
<button class="btn-action" id="xs-search" type="button" title="${escapeHtml(t("xlsx.tb_search"))}" aria-label="${escapeHtml(t("xlsx.tb_search"))}">
|
||||
${IC("search")}
|
||||
</button>
|
||||
</span>`);
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
|
||||
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
|
||||
@@ -48,6 +62,97 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="print">
|
||||
<button class="btn-action" id="xs-print" type="button" title="${escapeHtml(t("xlsx.print"))}" aria-label="${escapeHtml(t("xlsx.print"))}">
|
||||
${IC("printer")}
|
||||
</button>
|
||||
</span>`);
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="paint">
|
||||
<button class="btn-action" id="xs-paint" type="button" title="${escapeHtml(t("xlsx.tb_paint"))}" aria-label="${escapeHtml(t("xlsx.tb_paint"))}" aria-pressed="false">
|
||||
${IC("paint-roller")}
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="zoom">
|
||||
<select class="xlsx-select" id="xs-zoom" title="${escapeHtml(t("xlsx.tb_zoom"))}" aria-label="${escapeHtml(t("xlsx.tb_zoom"))}">
|
||||
${[50, 75, 90, 100, 125, 150, 200].map((z) =>
|
||||
`<option value="${z}"${z === 100 ? " selected" : ""}>${z} %</option>`).join("")}
|
||||
</select>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="number">
|
||||
<button class="btn-action" id="xs-cur" type="button" title="${escapeHtml(t("xlsx.tb_currency"))}" aria-label="${escapeHtml(t("xlsx.tb_currency"))}">
|
||||
${IC("dollar-sign")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-pct" type="button" title="${escapeHtml(t("xlsx.tb_percent"))}" aria-label="${escapeHtml(t("xlsx.tb_percent"))}">
|
||||
${IC("percent")}
|
||||
</button>
|
||||
<button class="btn-action xlsx-dec" id="xs-decminus" type="button" title="${escapeHtml(t("xlsx.tb_dec_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_minus"))}">.0←</button>
|
||||
<button class="btn-action xlsx-dec" id="xs-decplus" type="button" title="${escapeHtml(t("xlsx.tb_dec_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_plus"))}">.00→</button>
|
||||
<button class="btn-action" id="xs-num123" type="button" title="${escapeHtml(t("xlsx.tb_number"))}" aria-label="${escapeHtml(t("xlsx.tb_number"))}">123</button>
|
||||
<select class="xlsx-select" id="xs-numfmt" title="${escapeHtml(t("xlsx.tb_numfmt"))}" aria-label="${escapeHtml(t("xlsx.tb_numfmt"))}">
|
||||
<option value="" selected>${escapeHtml(t("xlsx.tb_numfmt"))}</option>
|
||||
<option value="General">${escapeHtml(t("xlsx.format_num_general"))}</option>
|
||||
<option value="0.00">${escapeHtml(t("xlsx.format_num_number"))}</option>
|
||||
<option value="0.0%">${escapeHtml(t("xlsx.format_num_percent"))}</option>
|
||||
<option value="#,##0.00 "€"">${escapeHtml(t("xlsx.format_num_currency"))}</option>
|
||||
<option value="DD/MM/YYYY">${escapeHtml(t("xlsx.format_num_date"))}</option>
|
||||
<option value="@">${escapeHtml(t("xlsx.format_num_text"))}</option>
|
||||
</select>
|
||||
</span>`);
|
||||
// #183 — colour commands open the Sheets-like panel built by
|
||||
// xlsx/color-picker.js (the native <input type="color"> is kept hidden and
|
||||
// only driven by the panel's « + » / crayon buttons).
|
||||
const colourPick = (id, label, iconName, swatchId) =>
|
||||
`<span class="xlsx-colorpick-wrap">`
|
||||
+ `<button class="btn-action xlsx-colorpick" id="${id}" type="button"`
|
||||
+ ` data-xlsx-colour="${swatchId === "xs-fgcolor" ? "font" : "fill"}"`
|
||||
+ ` title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}"`
|
||||
+ ` aria-haspopup="dialog" aria-expanded="false">`
|
||||
+ (iconName === "fill" ? IC("paint-bucket") : '<span class="xlsx-colorletter" aria-hidden="true">A</span>')
|
||||
+ `<span class="xlsx-color-swatch" data-swatch-for="${swatchId}" aria-hidden="true"></span>`
|
||||
+ `</button>`
|
||||
+ `<input type="color" class="xlsx-color-native" data-xlsx-native-for="${swatchId}" tabindex="-1" aria-hidden="true">`
|
||||
+ `</span>`;
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="font">
|
||||
<button class="btn-action" id="xs-fsminus" type="button" title="${escapeHtml(t("xlsx.tb_font_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_minus"))}">−</button>
|
||||
<select class="xlsx-select xlsx-fontsize" id="xs-fssize" title="${escapeHtml(t("xlsx.tb_font_size"))}" aria-label="${escapeHtml(t("xlsx.tb_font_size"))}">
|
||||
${[6, 7, 8, 9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) =>
|
||||
`<option value="${s}"${s === 11 ? " selected" : ""}>${s}</option>`).join("")}
|
||||
</select>
|
||||
<button class="btn-action" id="xs-fsplus" type="button" title="${escapeHtml(t("xlsx.tb_font_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_plus"))}">+</button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-b" type="button" title="${escapeHtml(t("xlsx.format_bold"))}" aria-label="${escapeHtml(t("xlsx.format_bold"))}" aria-pressed="false"><b>B</b></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-i" type="button" title="${escapeHtml(t("xlsx.format_italic"))}" aria-label="${escapeHtml(t("xlsx.format_italic"))}" aria-pressed="false"><i>I</i></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-s" type="button" title="${escapeHtml(t("xlsx.format_strike"))}" aria-label="${escapeHtml(t("xlsx.format_strike"))}" aria-pressed="false"><s>S</s></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-u" type="button" title="${escapeHtml(t("xlsx.format_underline"))}" aria-label="${escapeHtml(t("xlsx.format_underline"))}" aria-pressed="false"><u>U</u></button>
|
||||
${colourPick("xs-fgcolor", t("xlsx.format_font_color"), "font", "xs-fgcolor")}
|
||||
${colourPick("xs-bgcolor", t("xlsx.format_fill_color"), "fill", "xs-bgcolor")}
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="arrange">
|
||||
<button class="btn-action" id="xs-borders" type="button" title="${escapeHtml(t("xlsx.tb_borders"))}" aria-label="${escapeHtml(t("xlsx.tb_borders"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("grid-2x2")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-merge" type="button" title="${escapeHtml(t("xlsx.tb_merge"))}" aria-label="${escapeHtml(t("xlsx.tb_merge"))}">
|
||||
${IC("combine")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-alignh" type="button" title="${escapeHtml(t("xlsx.tb_alignh"))}" aria-label="${escapeHtml(t("xlsx.tb_alignh"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("align-left")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-alignv" type="button" title="${escapeHtml(t("xlsx.tb_alignv"))}" aria-label="${escapeHtml(t("xlsx.tb_alignv"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("align-center-vertical")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-wrap" type="button" title="${escapeHtml(t("xlsx.tb_wrap"))}" aria-label="${escapeHtml(t("xlsx.tb_wrap"))}" aria-pressed="false">
|
||||
${IC("wrap-text")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-rot" type="button" title="${escapeHtml(t("xlsx.tb_rotation"))}" aria-label="${escapeHtml(t("xlsx.tb_rotation"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("rotate-cw")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-more" type="button" title="${escapeHtml(t("xlsx.tb_more"))}" aria-label="${escapeHtml(t("xlsx.tb_more"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("more-vertical")}
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
@@ -64,26 +169,24 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
// #185 — the ribbon now reads like Google Sheets': flat icon buttons, no
|
||||
// chrome, grouped by thin vertical rules. The gridline switch belongs to it
|
||||
// (the View ▸ Show menu toggles the very same state).
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="grid">
|
||||
<button class="btn-action active" id="xs-grid" type="button" aria-pressed="true"
|
||||
title="${escapeHtml(t("xlsx.mi_show_grid"))}" aria-label="${escapeHtml(t("xlsx.mi_show_grid"))}">
|
||||
${IC("grid-3x3")}
|
||||
</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 class="export-dropdown xlsx-export-dropdown">
|
||||
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
|
||||
</button>
|
||||
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="md"><i data-lucide="file-text" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_md"))}</span></button>
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="html"><i data-lucide="code" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_html"))}</span></button>
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="print"><i data-lucide="printer" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.print"))}</span></button>
|
||||
</div>
|
||||
</span>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled><i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>
|
||||
</span>`);
|
||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
||||
|
||||
return { tabs, actionsHtml, statusBar };
|
||||
// #185 — file outputs moved to the **Fichier** menu of the menu bar
|
||||
// (frontend/js/xlsx/menus.js), which keeps the `#xlsx-download-btn` /
|
||||
// `#xlsx-csv-btn` ids and the `[data-xlsx-export]` markers. Only **Save**
|
||||
// stays on the sheet-tab strip, pinned at its right end.
|
||||
const fileHtml =
|
||||
`<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>`
|
||||
+ `<i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>`;
|
||||
|
||||
return { tabs, actionsHtml, statusBar, fileHtml };
|
||||
}
|
||||
|
||||
@@ -14,14 +14,15 @@ import { safeCreateIcons } from '../utils.js';
|
||||
|
||||
let _menu = null;
|
||||
let _openedAt = 0;
|
||||
// Only a touch long-press can emit a click right after the menu opened.
|
||||
let _openedByTouch = false;
|
||||
let _dismissBound = false;
|
||||
|
||||
export function closeContextMenu() {
|
||||
// BUG-108: drop the reference BEFORE touching the DOM — removing a menu
|
||||
// that holds the focus fires blur/focusout synchronously, which re-enters
|
||||
// this very function; the second remove() on the mid-flight node throws
|
||||
// NotFoundError and aborts the caller's click handler before its action
|
||||
// runs (contextual « Coller » silently dead, e2e xlsx-viewer A5).
|
||||
// Drop the reference BEFORE touching the DOM: removing a menu that holds
|
||||
// the focus fires blur/focusout synchronously, which re-enters this very
|
||||
// function — a second remove() on the mid-flight node throws NotFoundError
|
||||
// and aborts the caller's click handler before its action runs.
|
||||
const menu = _menu;
|
||||
_menu = null;
|
||||
if (menu) menu.remove();
|
||||
@@ -36,9 +37,11 @@ function bindDismiss() {
|
||||
_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;
|
||||
// Ignore the very gesture that opened the menu — but only for a touch
|
||||
// long-press, which is the one that emits a trailing click. A mouse
|
||||
// right-click does not, so a deliberate outside click made right after
|
||||
// must close the menu instead of being swallowed.
|
||||
if (_openedByTouch && Date.now() - _openedAt < 250) return;
|
||||
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
|
||||
closeContextMenu();
|
||||
});
|
||||
@@ -56,8 +59,12 @@ function bindDismiss() {
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("blur", closeContextMenu);
|
||||
// A scroll or resize detaches the viewport-anchored menu from its cell.
|
||||
window.addEventListener("scroll", closeContextMenu, true);
|
||||
// A scroll detaches the viewport-anchored menu from its cell — except the
|
||||
// tail of the smooth scroll that revealed the right-clicked cell (global
|
||||
// `scroll-behavior: smooth` keeps firing after the menu opened).
|
||||
window.addEventListener("scroll", () => {
|
||||
if (Date.now() - _openedAt > 400) closeContextMenu();
|
||||
}, true);
|
||||
window.addEventListener("resize", closeContextMenu);
|
||||
}
|
||||
}
|
||||
@@ -111,25 +118,50 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
|
||||
if (onClose) onClose();
|
||||
else menuEl.remove();
|
||||
};
|
||||
// A re-render can detach the menu without dismissing it: unbind silently
|
||||
// instead of leaking listeners on a dead node.
|
||||
const dropIfDetached = () => {
|
||||
if (!menuEl.isConnected) {
|
||||
if (!done) { done = true; unbind(); }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const onDocClick = (e) => {
|
||||
if (done) return;
|
||||
if (!menuEl.isConnected) { done = true; unbind(); return; }
|
||||
if (done || dropIfDetached()) return;
|
||||
const t = e.target;
|
||||
if (menuEl.contains(t)) return;
|
||||
if (toggle && (toggle === t || toggle.contains(t))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape" && menuEl.isConnected) dismiss();
|
||||
if (dropIfDetached()) return;
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
const onFocusOut = (e) => {
|
||||
if (done || !menuEl.isConnected) return;
|
||||
if (done || dropIfDetached()) return;
|
||||
const next = e.relatedTarget;
|
||||
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (menuEl.isConnected) dismiss();
|
||||
// Toolbar-anchored menus travel WITH their button: a scroll inside the
|
||||
// hosting toolbar (focus scroll-into-view, horizontal scroll, the menu's
|
||||
// own overflow) must not dismiss them — only an outside scroll detaches.
|
||||
// Same grace as the grid menu for the smooth-scroll tail of the opening.
|
||||
// A body-level menu has no travelling anchor: every scroll detaches it.
|
||||
const rawAnchor = (toggle && toggle.closest && toggle.closest(".xlsx-toolbar"))
|
||||
|| menuEl.parentElement
|
||||
|| null;
|
||||
const anchor = rawAnchor && rawAnchor !== document && rawAnchor !== document.body
|
||||
? rawAnchor
|
||||
: null;
|
||||
const openedAt = Date.now();
|
||||
const onScroll = (e) => {
|
||||
if (dropIfDetached()) return;
|
||||
if (Date.now() - openedAt < 400) return;
|
||||
const t = e && e.target;
|
||||
if (t && anchor && (t === anchor || (anchor.contains && anchor.contains(t)))) return;
|
||||
dismiss();
|
||||
};
|
||||
document.addEventListener("click", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
@@ -147,7 +179,7 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
|
||||
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @returns {HTMLElement} the menu element
|
||||
*/
|
||||
export function openContextMenu(x, y, items) {
|
||||
export function openContextMenu(x, y, items, { byTouch = false } = {}) {
|
||||
bindDismiss();
|
||||
closeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
@@ -198,6 +230,7 @@ export function openContextMenu(x, y, items) {
|
||||
|
||||
_menu = menu;
|
||||
_openedAt = Date.now();
|
||||
_openedByTouch = byTouch;
|
||||
// Keyboard users land on the first entry right away; mouse users are
|
||||
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
|
||||
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
/* ObsiGate — minimal client-side spreadsheet formula engine.
|
||||
*
|
||||
* Evaluates `=…` / `@…` cell content locally (same sheet, plus loaded
|
||||
* cross-sheet panels) so functions work without a round-trip: arithmetic,
|
||||
* comparisons, %, cell/range references and ~30 FR/EN functions.
|
||||
*
|
||||
* Safe by construction: own tokenizer + recursive-descent parser, no eval().
|
||||
* Anything the parser rejects keeps its original rendering (Excel stays the
|
||||
* source of truth on save); evaluation failures surface as Excel-style error
|
||||
* codes instead.
|
||||
*
|
||||
* Limits (documented): same-workbook only, no whole-column (A:A) references,
|
||||
* TODAY()/NOW() refresh on edit/render (no timer), array results show their
|
||||
* top-left value.
|
||||
*/
|
||||
|
||||
const MAX_COL = 18278; // XFD
|
||||
const MAX_ROW = 1048576;
|
||||
|
||||
/** FR aliases accepted next to the EN canonical names. */
|
||||
const ALIASES = {
|
||||
SOMME: "SUM", MOYENNE: "AVERAGE", NB: "COUNT", NBVAL: "COUNTA",
|
||||
PRODUIT: "PRODUCT", MEDIANE: "MEDIAN", ARRONDI: "ROUND", ENT: "INT",
|
||||
RACINE: "SQRT", PUISSANCE: "POWER", SI: "IF", ET: "AND", OU: "OR",
|
||||
NON: "NOT", SIERREUR: "IFERROR", CONCATENER: "CONCAT",
|
||||
MAJUSCULES: "UPPER", MINUSCULES: "LOWER", NBCAR: "LEN",
|
||||
SUPPRESPACE: "TRIM", GAUCHE: "LEFT", DROITE: "RIGHT", STXT: "MID",
|
||||
AUJOURDHUI: "TODAY", MAINTENANT: "NOW", JOUR: "DAY", MOIS: "MONTH",
|
||||
ANNEE: "YEAR", LIEN: "HYPERLINK",
|
||||
};
|
||||
|
||||
const ERRORS = {
|
||||
fr: { DIV: "#DIV/0!", VALUE: "#VALEUR!", NAME: "#NOM?", REF: "#REF!", CYCLE: "#CYCLE!" },
|
||||
en: { DIV: "#DIV/0!", VALUE: "#VALUE!", NAME: "#NAME?", REF: "#REF!", CIRC: "#CIRC!" },
|
||||
};
|
||||
|
||||
export function errorCodes(locale) {
|
||||
return ERRORS[locale === "fr" ? "fr" : "en"];
|
||||
}
|
||||
|
||||
export function isFormulaText(s) {
|
||||
if (typeof s !== "string") return false;
|
||||
const t = s.trim();
|
||||
return t.length > 1 && (t[0] === "=" || t[0] === "@");
|
||||
}
|
||||
|
||||
/** Column letters (no $) → 1-based index, or 0 when invalid. */
|
||||
export function colToIndex(letters) {
|
||||
let n = 0;
|
||||
const up = String(letters || "").toUpperCase();
|
||||
if (!/^[A-Z]{1,3}$/.test(up)) return 0;
|
||||
for (const ch of up) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n > MAX_COL ? 0 : n;
|
||||
}
|
||||
|
||||
export function indexToCol(n) {
|
||||
let s = "";
|
||||
while (n > 0) {
|
||||
const m = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + m) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Parse "[$]A[$]1" (already stripped of any sheet prefix) → {col,row} | null. */
|
||||
export function parseA1(ref) {
|
||||
const m = /^\$?([A-Za-z]{1,3})\$?([0-9]+)$/.exec(String(ref || "").trim());
|
||||
if (!m) return null;
|
||||
const col = colToIndex(m[1]);
|
||||
const row = Number(m[2]);
|
||||
if (!col || !(row >= 1 && row <= MAX_ROW)) return null;
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function tokenize(src, sep) {
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
while (i < n) {
|
||||
const c = src[i];
|
||||
if (isSpace(c)) { i++; continue; }
|
||||
if (c === '"') {
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n) {
|
||||
if (src[j] === '"') {
|
||||
if (src[j + 1] === '"') { out += '"'; j += 2; }
|
||||
else break;
|
||||
} else { out += src[j]; j++; }
|
||||
}
|
||||
if (j >= n) throw new Error("string");
|
||||
tokens.push({ t: "str", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
// Quoted sheet name: 'Mon Sheet'!A1
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n && src[j] !== "'") { out += src[j]; j++; }
|
||||
if (j >= n) throw new Error("sheet");
|
||||
tokens.push({ t: "sheet", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
const numMatch = /^[0-9]+(?:[.,][0-9]+)?/.exec(src.slice(i));
|
||||
if (numMatch && (sep === ";" || !numMatch[0].includes(","))) {
|
||||
tokens.push({ t: "num", v: numMatch[0].replace(",", ".") });
|
||||
i += numMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
const absRef = /^\$?[A-Za-z]{1,3}\$?[0-9]+/.exec(src.slice(i));
|
||||
if (absRef) {
|
||||
tokens.push({ t: "ref", v: absRef[0] });
|
||||
i += absRef[0].length;
|
||||
continue;
|
||||
}
|
||||
const two = src.slice(i, i + 2);
|
||||
if (["<=", ">=", "<>"].includes(two)) {
|
||||
tokens.push({ t: "op", v: two === "<>" ? "!=" : two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("+-*/^%=()<>,;:&!".includes(c)) {
|
||||
tokens.push({ t: c === "," || c === ";" ? "sep" : "op", v: c });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const word = /^[A-Za-zÀ-ÿ_][A-Za-zÀ-ÿ0-9_.]*/.exec(src.slice(i));
|
||||
if (word) {
|
||||
const w = word[0];
|
||||
// A bare cell reference (A1, $B$12) — but not TRUE/FALSE yet (bools).
|
||||
if (/^\$?[A-Za-z]{1,3}\$?[0-9]+$/.test(w)) {
|
||||
tokens.push({ t: "ref", v: w });
|
||||
} else {
|
||||
const up = w.toUpperCase();
|
||||
if (up === "TRUE" || up === "VRAI") tokens.push({ t: "bool", v: true });
|
||||
else if (up === "FALSE" || up === "FAUX") tokens.push({ t: "bool", v: false });
|
||||
else tokens.push({ t: "word", v: w });
|
||||
}
|
||||
i += w.length;
|
||||
continue;
|
||||
}
|
||||
throw new Error("char:" + c);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── Parser (recursive descent, builds a small AST) ─────────────────────────
|
||||
|
||||
function makeParser(tokens, sep) {
|
||||
let pos = 0;
|
||||
const peek = () => tokens[pos] || null;
|
||||
const next = () => tokens[pos++] || null;
|
||||
const expectOp = (v) => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "op" || t.v !== v) throw new Error("expected " + v);
|
||||
};
|
||||
const expectSep = () => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "sep") throw new Error("expected separator");
|
||||
};
|
||||
|
||||
function parseExpr() { return parseComparison(); }
|
||||
function parseComparison() {
|
||||
let left = parseConcat();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || !["=", "!=", "<", "<=", ">", ">="].includes(t.v)) return left;
|
||||
next();
|
||||
left = [t.v, left, parseConcat()];
|
||||
}
|
||||
}
|
||||
function parseConcat() {
|
||||
let left = parseAdd();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || t.v !== "&") return left;
|
||||
next();
|
||||
left = ["&", left, parseAdd()];
|
||||
}
|
||||
}
|
||||
function parseAdd() {
|
||||
let left = parseMul();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "+" && t.v !== "-")) return left;
|
||||
next();
|
||||
left = [t.v, left, parseMul()];
|
||||
}
|
||||
}
|
||||
function parseMul() {
|
||||
let left = parsePower();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "*" && t.v !== "/")) return left;
|
||||
next();
|
||||
left = [t.v, left, parsePower()];
|
||||
}
|
||||
}
|
||||
// Excel-compatible: a leading sign binds to the base (-2^2 = (-2)^2 = 4),
|
||||
// and to the exponent on the right (2^-2).
|
||||
function parseSigned() {
|
||||
let neg = false;
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && (t.v === "+" || t.v === "-")) {
|
||||
if (t.v === "-") neg = !neg;
|
||||
next();
|
||||
} else break;
|
||||
}
|
||||
const node = parsePostfix();
|
||||
return neg ? ["neg", node] : node;
|
||||
}
|
||||
function parsePower() {
|
||||
const base = parseSigned();
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "^") {
|
||||
next();
|
||||
return ["^", base, parseSigned()];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
function parsePostfix() {
|
||||
let node = parsePrimary();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "%") { next(); node = ["%", node]; }
|
||||
else return node;
|
||||
}
|
||||
}
|
||||
function parsePrimary() {
|
||||
const t = next();
|
||||
if (!t) throw new Error("empty");
|
||||
if (t.t === "num") return ["n", Number(t.v)];
|
||||
if (t.t === "str") return ["s", t.v];
|
||||
if (t.t === "bool") return ["b", t.v];
|
||||
if (t.t === "ref") {
|
||||
const after = peek();
|
||||
if (after && after.t === "op" && after.v === ":") {
|
||||
next();
|
||||
const t2 = next();
|
||||
if (!t2 || t2.t !== "ref") throw new Error("range");
|
||||
return ["range", normRef(t.v), normRef(t2.v)];
|
||||
}
|
||||
return ["ref", normRef(t.v)];
|
||||
}
|
||||
if (t.t === "word" || t.t === "sheet") {
|
||||
const isSheet = t.t === "sheet";
|
||||
let name = t.v;
|
||||
if (!isSheet) {
|
||||
// Unquoted sheet name: Name!A1
|
||||
const bang = peek();
|
||||
if (bang && bang.t === "op" && bang.v === "!") {
|
||||
next();
|
||||
} else {
|
||||
// Function call?
|
||||
const paren = peek();
|
||||
if (paren && paren.t === "op" && paren.v === "(") {
|
||||
next();
|
||||
const args = [];
|
||||
const closing = peek();
|
||||
if (!(closing && closing.t === "op" && closing.v === ")")) {
|
||||
for (;;) {
|
||||
args.push(parseExpr());
|
||||
const s = peek();
|
||||
if (s && s.t === "sep") { next(); continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
expectOp(")");
|
||||
return ["call", canonName(name), args];
|
||||
}
|
||||
throw new Error("name:" + name);
|
||||
}
|
||||
} else {
|
||||
const bang = peek();
|
||||
if (!bang || bang.t !== "op" || bang.v !== "!") throw new Error("sheetref");
|
||||
next();
|
||||
}
|
||||
const r1 = next();
|
||||
if (!r1 || r1.t !== "ref") throw new Error("sheetref");
|
||||
const colon = peek();
|
||||
if (colon && colon.t === "op" && colon.v === ":") {
|
||||
next();
|
||||
const r2 = next();
|
||||
if (!r2 || r2.t !== "ref") throw new Error("sheetrange");
|
||||
return ["sheetrange", name, normRef(r1.v), normRef(r2.v)];
|
||||
}
|
||||
return ["sheetref", name, normRef(r1.v)];
|
||||
}
|
||||
if (t.t === "op" && t.v === "(") {
|
||||
const e = parseExpr();
|
||||
expectOp(")");
|
||||
return e;
|
||||
}
|
||||
throw new Error("primary");
|
||||
}
|
||||
function normRef(r) {
|
||||
return r.replace(/\$/g, "").toUpperCase();
|
||||
}
|
||||
function canonName(name) {
|
||||
const up = String(name).toUpperCase();
|
||||
return ALIASES[up] || up;
|
||||
}
|
||||
return { parseExpr, atEnd: () => pos >= tokens.length };
|
||||
}
|
||||
|
||||
export function parseFormula(text) {
|
||||
const body = String(text || "").trim().replace(/^[=@]/, "");
|
||||
const sep = body.includes(";") ? ";" : ",";
|
||||
// A comma is a decimal mark (FR style) when ';' is the separator, or when
|
||||
// the expression holds no letters at all ("=1,5+1" can only mean 1.5 + 1).
|
||||
const commaDecimal = sep === ";" || !/[A-Za-zÀ-ÿ]/.test(body);
|
||||
const { parseExpr, atEnd } = makeParser(tokenize(body, commaDecimal ? ";" : sep), sep);
|
||||
const ast = parseExpr();
|
||||
if (!atEnd()) throw new Error("trailing");
|
||||
return ast;
|
||||
}
|
||||
|
||||
// ── Evaluator ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Engine scope: resolve raw cell values + locale + cycle guard. */
|
||||
export function createScope({ readRaw, sheet, locale = "en", seen = null } = {}) {
|
||||
const scope = {
|
||||
sheet: sheet || "",
|
||||
locale: locale === "fr" ? "fr" : "en",
|
||||
seen: seen || new Set(),
|
||||
memo: new Map(),
|
||||
/** Engine value of `sheet!ref` (formulas recurse, cycles guarded). */
|
||||
getCell(targetSheet, ref) {
|
||||
const key = `${targetSheet || scope.sheet}!${String(ref).toUpperCase()}`;
|
||||
const raw = readRaw ? readRaw(targetSheet || scope.sheet, ref) : null;
|
||||
return evalRefValue(raw, scope, key);
|
||||
},
|
||||
};
|
||||
return scope;
|
||||
}
|
||||
|
||||
const err = (code) => ({ t: "e", code });
|
||||
const isErr = (v) => v && v.t === "e";
|
||||
const isBlank = (v) => !v || v.t === "blank";
|
||||
|
||||
function toNumber(v) {
|
||||
if (!v || v.t === "blank") return 0;
|
||||
if (v.t === "n") return v.v;
|
||||
if (v.t === "b") return v.v ? 1 : 0;
|
||||
if (v.t === "s") {
|
||||
const s = v.v.trim().replace(",", ".");
|
||||
if (s === "") return 0;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toText(v, locale) {
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function numText(n) {
|
||||
if (!Number.isFinite(n)) return String(n);
|
||||
if (Number.isInteger(n) && Math.abs(n) < 1e15) return String(n);
|
||||
return String(parseFloat(n.toPrecision(10)));
|
||||
}
|
||||
|
||||
function flattenArgs(vals) {
|
||||
const out = [];
|
||||
const walk = (v) => {
|
||||
if (v && v.t === "a") v.rows.forEach((r) => r.forEach(walk));
|
||||
else out.push(v);
|
||||
};
|
||||
vals.forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
function evalRefValue(raw, scope, key) {
|
||||
// Raw storage text → engine value (formulas recurse, cycles guarded).
|
||||
if (raw === null || raw === undefined) return { t: "blank" };
|
||||
if (typeof raw === "number") return { t: "n", v: raw };
|
||||
if (typeof raw === "boolean") return { t: "b", v: raw };
|
||||
const s = String(raw);
|
||||
if (scope.memo.has(key)) return scope.memo.get(key);
|
||||
let out;
|
||||
if (isFormulaText(s)) {
|
||||
if (scope.seen.has(key)) {
|
||||
out = err("CYCLE");
|
||||
} else {
|
||||
scope.seen.add(key);
|
||||
try {
|
||||
out = evaluate(parseFormula(s), scope);
|
||||
} catch (_) {
|
||||
out = err("NAME");
|
||||
}
|
||||
scope.seen.delete(key);
|
||||
}
|
||||
} else if (s.trim() === "") {
|
||||
out = { t: "blank" };
|
||||
} else {
|
||||
const n = Number(s.trim().replace(",", "."));
|
||||
out = s.trim() !== "" && Number.isFinite(n) && /^[-+0-9.,\s]+$/.test(s.trim())
|
||||
? { t: "n", v: n }
|
||||
: { t: "s", v: s };
|
||||
}
|
||||
scope.memo.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function rangeValues(r1, r2, scope) {
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rStart = Math.min(a.row, b.row);
|
||||
const rEnd = Math.max(a.row, b.row);
|
||||
const cStart = Math.min(a.col, b.col);
|
||||
const cEnd = Math.max(a.col, b.col);
|
||||
if ((rEnd - rStart + 1) * (cEnd - cStart + 1) > 100000) return err("VALUE");
|
||||
const rows = [];
|
||||
for (let r = rStart; r <= rEnd; r++) {
|
||||
const line = [];
|
||||
for (let c = cStart; c <= cEnd; c++) {
|
||||
const ref = indexToCol(c) + r;
|
||||
line.push(scope.getCell(scope.sheet, ref));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
|
||||
function firstOf(v) {
|
||||
if (v && v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return cell;
|
||||
}
|
||||
return { t: "blank" };
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function cmpValues(a, b) {
|
||||
// Excel-ish: numbers < text; blank sorts with text "".
|
||||
const rank = (v) => (!v || v.t === "blank" ? 0 : v.t === "n" ? 1 : v.t === "b" ? 2 : 3);
|
||||
const ra = rank(a);
|
||||
const rb = rank(b);
|
||||
if (ra !== rb) return ra - rb;
|
||||
if (ra === 1) return a.v - b.v;
|
||||
if (ra === 2) return (a.v ? 1 : 0) - (b.v ? 1 : 0);
|
||||
const sa = toText(a);
|
||||
const sb = toText(b);
|
||||
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||
}
|
||||
|
||||
const FUNCTIONS = {
|
||||
SUM: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc + n;
|
||||
}, 0)),
|
||||
AVERAGE: (vals, scope) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
if (!nums.length) return err("DIV");
|
||||
return num(nums.reduce((a, b) => a + b, 0) / nums.length);
|
||||
},
|
||||
MIN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.min(...nums)) : num(0);
|
||||
},
|
||||
MAX: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.max(...nums)) : num(0);
|
||||
},
|
||||
COUNT: (vals) => num(flattenArgs(vals).filter((v) => v && v.t === "n").length),
|
||||
COUNTA: (vals) => num(flattenArgs(vals).filter((v) => !isBlank(v)).length),
|
||||
PRODUCT: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc * n;
|
||||
}, 1)),
|
||||
MEDIAN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null).sort((a, b) => a - b);
|
||||
if (!nums.length) return err("DIV");
|
||||
const m = Math.floor(nums.length / 2);
|
||||
return num(nums.length % 2 ? nums[m] : (nums[m - 1] + nums[m]) / 2);
|
||||
},
|
||||
ABS: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.abs(n));
|
||||
},
|
||||
ROUND: ([v, d]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
const digits = d === undefined ? 0 : toNumber(firstOf(d));
|
||||
if (n === null || digits === null) return err("VALUE");
|
||||
const f = 10 ** Math.trunc(digits);
|
||||
return num(Math.round(n * f) / f);
|
||||
},
|
||||
INT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.floor(n));
|
||||
},
|
||||
SQRT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
if (n === null) return err("VALUE");
|
||||
if (n < 0) return err("VALUE");
|
||||
return num(Math.sqrt(n));
|
||||
},
|
||||
POWER: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null) return err("VALUE");
|
||||
const r = x ** y;
|
||||
return Number.isFinite(r) ? num(r) : err("VALUE");
|
||||
},
|
||||
MOD: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null || y === 0) return y === 0 ? err("DIV") : err("VALUE");
|
||||
return num(((x % y) + y) % y);
|
||||
},
|
||||
CONCAT: (vals, scope) => ({ t: "s", v: flattenArgs(vals).map((v) => toText(v, scope.locale)).join("") }),
|
||||
UPPER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toUpperCase() }),
|
||||
LOWER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toLowerCase() }),
|
||||
LEN: ([v], scope) => num([...toText(firstOf(v), scope.locale)].length),
|
||||
TRIM: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).trim().replace(/\s+/g, " ") }),
|
||||
LEFT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: s.slice(0, Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
RIGHT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: k <= 0 ? "" : s.slice(-Math.trunc(k)) };
|
||||
},
|
||||
MID: ([v, start, len]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const a = toNumber(firstOf(start));
|
||||
const k = len === undefined ? s.length : toNumber(firstOf(len));
|
||||
if (a === null || k === null || a < 1) return err("VALUE");
|
||||
return { t: "s", v: s.slice(Math.trunc(a) - 1, Math.trunc(a) - 1 + Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
TODAY: () => ({ t: "s", v: new Date().toLocaleDateString() }),
|
||||
NOW: () => ({ t: "s", v: new Date().toLocaleString() }),
|
||||
DAY: ([v]) => datePart(v, "day"),
|
||||
MONTH: ([v]) => datePart(v, "month"),
|
||||
YEAR: ([v]) => datePart(v, "year"),
|
||||
NOT: ([v]) => ({ t: "b", v: !toBool(firstOf(v)) }),
|
||||
HYPERLINK: ([url, label], scope) => {
|
||||
const u = toText(firstOf(url), scope.locale).trim();
|
||||
if (!/^https?:\/\//i.test(u)) return err("VALUE");
|
||||
const text = label === undefined ? u : toText(firstOf(label), scope.locale);
|
||||
return { t: "link", url: u, label: text };
|
||||
},
|
||||
};
|
||||
|
||||
function num(n) {
|
||||
return { t: "n", v: n };
|
||||
}
|
||||
|
||||
function toBool(v) {
|
||||
if (!v || v.t === "blank") return false;
|
||||
if (v.t === "b") return v.v;
|
||||
if (v.t === "n") return v.v !== 0;
|
||||
const s = v.t === "s" ? v.v.trim().toUpperCase() : "";
|
||||
if (["TRUE", "VRAI", "1", "YES", "OUI"].includes(s)) return true;
|
||||
if (["FALSE", "FAUX", "0", "", "NO", "NON"].includes(s)) return false;
|
||||
return s !== "";
|
||||
}
|
||||
|
||||
function datePart(v, part) {
|
||||
const first = firstOf(v);
|
||||
let d = null;
|
||||
if (first && first.t === "s") {
|
||||
const parsed = new Date(first.v);
|
||||
if (!Number.isNaN(parsed.getTime())) d = parsed;
|
||||
} else if (first && first.t === "n") {
|
||||
// Excel serial date (1900 system).
|
||||
const base = Date.UTC(1899, 11, 30);
|
||||
d = new Date(base + Math.trunc(first.v) * 86400000);
|
||||
}
|
||||
if (!d) return err("VALUE");
|
||||
if (part === "day") return num(d.getDate());
|
||||
if (part === "month") return num(d.getMonth() + 1);
|
||||
return num(d.getFullYear());
|
||||
}
|
||||
|
||||
function callFunction(name, argNodes, scope) {
|
||||
if (name === "IF") {
|
||||
if (argNodes.length < 2 || argNodes.length > 3) return err("NAME");
|
||||
const cond = evaluate(argNodes[0], scope);
|
||||
if (isErr(cond)) return cond;
|
||||
const branch = toBool(cond) ? argNodes[1] : argNodes[2];
|
||||
if (!branch) return { t: "b", v: false };
|
||||
return evaluate(branch, scope);
|
||||
}
|
||||
if (name === "AND" || name === "OR") {
|
||||
let out = name === "AND";
|
||||
for (const node of argNodes) {
|
||||
const v = evaluate(node, scope);
|
||||
if (isErr(v)) return v;
|
||||
const b = toBool(v);
|
||||
if (name === "AND" && !b) return { t: "b", v: false };
|
||||
if (name === "OR" && b) return { t: "b", v: true };
|
||||
out = b;
|
||||
}
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
if (name === "IFERROR") {
|
||||
if (!argNodes.length || argNodes.length > 2) return err("NAME");
|
||||
const v = evaluate(argNodes[0], scope);
|
||||
if (!isErr(v)) return v;
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") return v;
|
||||
return argNodes[1] ? evaluate(argNodes[1], scope) : { t: "blank" };
|
||||
}
|
||||
const fn = FUNCTIONS[name];
|
||||
if (!fn) return err("NAME");
|
||||
const vals = argNodes.map((node) => evaluate(node, scope));
|
||||
for (const v of vals) {
|
||||
if (isErr(v)) {
|
||||
// Errors propagate, except through aggregations over ranges.
|
||||
if (["SUM", "AVERAGE", "MIN", "MAX", "COUNT", "COUNTA", "PRODUCT", "MEDIAN", "CONCAT"].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return fn(vals, scope);
|
||||
}
|
||||
|
||||
export function evaluate(node, scope) {
|
||||
if (Array.isArray(node)) {
|
||||
const [kind, ...rest] = node;
|
||||
switch (kind) {
|
||||
case "n": return { t: "n", v: rest[0] };
|
||||
case "s": return { t: "s", v: rest[0] };
|
||||
case "b": return { t: "b", v: rest[0] };
|
||||
case "neg": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(-n);
|
||||
}
|
||||
case "%": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(n / 100);
|
||||
}
|
||||
case "ref": {
|
||||
const p = parseA1(rest[0]);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(scope.sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "range": return rangeValues(rest[0], rest[1], scope);
|
||||
case "sheetref": {
|
||||
const [, sheet, ref] = node;
|
||||
const p = parseA1(ref);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "sheetrange": {
|
||||
const [, sheet, r1, r2] = node;
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rows = [];
|
||||
for (let r = Math.min(a.row, b.row); r <= Math.max(a.row, b.row); r++) {
|
||||
const line = [];
|
||||
for (let c = Math.min(a.col, b.col); c <= Math.max(a.col, b.col); c++) {
|
||||
line.push(scope.getCell(sheet, indexToCol(c) + r));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
case "call": return callFunction(rest[0], rest[1], scope);
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
case "^":
|
||||
case "&": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
if (kind === "&") return { t: "s", v: toText(l, scope.locale) + toText(r, scope.locale) };
|
||||
const a = toNumber(l);
|
||||
const b = toNumber(r);
|
||||
if (a === null || b === null) return err("VALUE");
|
||||
if (kind === "+") return num(a + b);
|
||||
if (kind === "-") return num(a - b);
|
||||
if (kind === "*") return num(a * b);
|
||||
if (kind === "/") return b === 0 ? err("DIV") : num(a / b);
|
||||
if (kind === "^") {
|
||||
const v = a ** b;
|
||||
return Number.isFinite(v) ? num(v) : err("VALUE");
|
||||
}
|
||||
return err("VALUE");
|
||||
}
|
||||
case "=":
|
||||
case "!=":
|
||||
case "<":
|
||||
case "<=":
|
||||
case ">":
|
||||
case ">=": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
const c = cmpValues(l, r);
|
||||
const out = kind === "=" ? c === 0 : kind === "!=" ? c !== 0
|
||||
: kind === "<" ? c < 0 : kind === "<=" ? c <= 0
|
||||
: kind === ">" ? c > 0 : c >= 0;
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
default:
|
||||
return err("NAME");
|
||||
}
|
||||
}
|
||||
return err("NAME");
|
||||
}
|
||||
|
||||
/** Render an evaluated value for display (localized booleans/errors). */
|
||||
export function displayValue(v, locale = "en") {
|
||||
const codes = errorCodes(locale);
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
if (v.t === "e") {
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") {
|
||||
return locale === "fr" ? codes.CYCLE : codes.CIRC;
|
||||
}
|
||||
return codes[v.code] || codes.VALUE;
|
||||
}
|
||||
if (v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return displayValue(cell, locale);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* ObsiGate — XLSX menu bar (the ten Google Sheets menus).
|
||||
*
|
||||
* Pure declarative data + a string builder, in the same spirit as
|
||||
* `command-bar.js`: the menu tree lives here and stays testable on its own,
|
||||
* while `viewer.js` only resolves each entry's symbolic `action` against its
|
||||
* own flows. No DOM here beyond `innerHTML` of the shell.
|
||||
*
|
||||
* Entry shape
|
||||
* -----------
|
||||
* { labelKey, icon, action, children, unavailable, danger, needs, sep }
|
||||
* - `labelKey` i18n key of the visible label (never raw text).
|
||||
* - `icon` Lucide icon name, hydrated later by `safeCreateIcons()`.
|
||||
* - `action` symbolic name resolved by the viewer. An entry with no
|
||||
* action — or flagged `unavailable` — is rendered honestly and
|
||||
* reports that ObsiGate does not implement it: a documented
|
||||
* dead end, never a silent no-op.
|
||||
* - `children` a Sheets-style accordion section (Nombre, Thème, Zoom, …):
|
||||
* collapsed, expanded by a click on its parent row.
|
||||
* - `needs` `"editable"` (hide unless the workbook can be written) or
|
||||
* `"notReadOnly"` (hide only for .xls/.ods).
|
||||
* - `sep` a thin rule instead of an entry.
|
||||
*
|
||||
* The Fichier menu keeps the historical `#xlsx-file-btn` / `#xlsx-file-menu` /
|
||||
* `#xlsx-download-btn` / `#xlsx-csv-btn` ids and the `[data-xlsx-export]`
|
||||
* markers: the File menu moved from the sheet strip to the menu bar, but every
|
||||
* consumer (viewer wiring, tests, E2E) keeps working untouched.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
const IC = (name) => `<i data-lucide="${name}" class="xlsx-menu-icon" aria-hidden="true"></i>`;
|
||||
|
||||
/** Google Sheets' menu bar: same order, same entries, same French labels. */
|
||||
export const SHEETS_MENUS = [
|
||||
{
|
||||
id: "file",
|
||||
labelKey: "xlsx.mbar_file",
|
||||
btnId: "xlsx-file-btn",
|
||||
dropId: "xlsx-file-menu",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_new", icon: "file-plus", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_open", icon: "folder-open", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_import", icon: "file-input", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_make_copy", icon: "copy", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_share", icon: "share-2", action: "share" },
|
||||
{ labelKey: "xlsx.mi_email", icon: "mail", unavailable: true },
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_download",
|
||||
icon: "download",
|
||||
children: [
|
||||
{ labelKey: "viewer.download", icon: "download", id: "xlsx-download-btn", action: "download" },
|
||||
{ labelKey: "xlsx.export_csv", icon: "file-spreadsheet", id: "xlsx-csv-btn", action: "export-csv", titleKey: "xlsx.csv_export" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.export_md", icon: "file-text", export: "md", action: "export-md" },
|
||||
{ labelKey: "xlsx.export_html", icon: "code", export: "html", action: "export-html" },
|
||||
{ labelKey: "xlsx.print", icon: "printer", export: "print", action: "print" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_rename", icon: "pencil", action: "rename" },
|
||||
{ labelKey: "xlsx.mi_trash", icon: "trash-2", action: "trash", danger: true },
|
||||
{ labelKey: "xlsx.mi_history", icon: "history", action: "history" },
|
||||
{ labelKey: "xlsx.mi_offline", icon: "cloud-off", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_details", icon: "info", action: "details" },
|
||||
{ labelKey: "xlsx.mi_settings", icon: "settings", action: "settings" },
|
||||
{ labelKey: "xlsx.print", icon: "printer", action: "print" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
labelKey: "xlsx.mbar_edit",
|
||||
items: [
|
||||
{ labelKey: "xlsx.undo", icon: "undo-2", action: "undo", needs: "notReadOnly" },
|
||||
{ labelKey: "xlsx.redo", icon: "redo-2", action: "redo", needs: "notReadOnly" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.cut", icon: "scissors", action: "cut", needs: "editable" },
|
||||
{ labelKey: "xlsx.copy", icon: "copy", action: "copy" },
|
||||
{ labelKey: "xlsx.paste", icon: "clipboard-paste", action: "paste", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_paste_special",
|
||||
icon: "clipboard-paste",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_paste_all", icon: "clipboard-paste", action: "paste" },
|
||||
{ labelKey: "xlsx.mi_paste_values", icon: "pilcrow", action: "paste-values" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_move",
|
||||
icon: "move",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_move_rows", icon: "rows", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_move_cols", icon: "columns", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_delete",
|
||||
icon: "trash-2",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_delete_rows", icon: "rows", action: "delete-rows", danger: true },
|
||||
{ labelKey: "xlsx.mi_delete_cols", icon: "columns", action: "delete-cols", danger: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.clear_contents", icon: "eraser", action: "clear-contents" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_select_all", icon: "check-check", action: "select-all" },
|
||||
{ labelKey: "xlsx.mi_replace", icon: "replace", action: "find-replace", needs: "editable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
labelKey: "xlsx.mbar_view",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_freeze",
|
||||
icon: "snowflake",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_freeze_here", icon: "snowflake", action: "freeze" },
|
||||
{ labelKey: "xlsx.mi_freeze_row", icon: "rows", action: "freeze-row" },
|
||||
{ labelKey: "xlsx.mi_freeze_col", icon: "columns", action: "freeze-col" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.format_unfreeze", icon: "sun", action: "unfreeze" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_show",
|
||||
icon: "eye",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_show_formula", icon: "chevrons-right", action: "toggle-formula-bar" },
|
||||
{ labelKey: "xlsx.mi_show_grid", icon: "grid-3x3", action: "toggle-grid" },
|
||||
{ labelKey: "xlsx.mi_show_headers", icon: "rows-3", action: "toggle-headers" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_show_dashboard", icon: "layout-dashboard", action: "dashboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_group",
|
||||
icon: "group",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_group_rows", icon: "rows", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_group_cols", icon: "columns", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_ungroup_rows", icon: "ungroup", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_ungroup_cols", icon: "ungroup", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_hidden_sheets",
|
||||
icon: "eye-off",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_hide_sheet", icon: "eye-off", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_unhide_sheets", icon: "eye", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_zoom",
|
||||
icon: "zoom-in",
|
||||
children: [50, 75, 90, 100, 125, 150, 200].map((z) => ({
|
||||
labelKey: null,
|
||||
label: `${z} %`,
|
||||
icon: z === 100 ? "crosshair" : null,
|
||||
action: "zoom",
|
||||
arg: z,
|
||||
})),
|
||||
},
|
||||
{ labelKey: "xlsx.mi_fullscreen", icon: "fullscreen", action: "fullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "insert",
|
||||
labelKey: "xlsx.mbar_insert",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_insert_rows",
|
||||
icon: "rows",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.insert_row_above", icon: "arrow-up", action: "insert-rows-above" },
|
||||
{ labelKey: "xlsx.insert_row_below", icon: "arrow-down", action: "insert-rows-below" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_insert_cols",
|
||||
icon: "columns",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.insert_col_left", icon: "arrow-left", action: "insert-cols-left" },
|
||||
{ labelKey: "xlsx.insert_col_right", icon: "arrow-right", action: "insert-cols-right" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_insert_cells", icon: "grid-2x2-plus", action: "insert-cells", needs: "editable" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_image", icon: "image", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_chart", icon: "bar-chart-3", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_drawing", icon: "pen-tool", unavailable: true },
|
||||
{ labelKey: "xlsx.link_add", icon: "link", action: "link", needs: "editable" },
|
||||
{ labelKey: "xlsx.comment_add", icon: "message-square", action: "comment", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_function",
|
||||
icon: "function-square",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_function_hint",
|
||||
action: "function",
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_pivot", icon: "table-2", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_smart_chips", icon: "tags", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_form", icon: "clipboard-list", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
labelKey: "xlsx.mbar_format",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_number",
|
||||
icon: "hash",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_num_general", icon: "type", action: "numfmt", arg: "General" },
|
||||
{ labelKey: "xlsx.format_num_number", icon: "hash", action: "numfmt", arg: "0.00" },
|
||||
{ labelKey: "xlsx.format_num_percent", icon: "percent", action: "numfmt", arg: "0.0%" },
|
||||
{ labelKey: "xlsx.format_num_currency", icon: "euro", action: "numfmt", arg: '#,##0.00 "€"' },
|
||||
{ labelKey: "xlsx.format_num_date", icon: "calendar", action: "numfmt", arg: "DD/MM/YYYY" },
|
||||
{ labelKey: "xlsx.format_num_text", icon: "pilcrow", action: "numfmt", arg: "@" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_theme",
|
||||
icon: "swatch-book",
|
||||
// Filled on demand by the viewer (the theme list is built from
|
||||
// themes.js, so it must not be hard-coded here).
|
||||
children: [],
|
||||
action: "theme-menu",
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.format_bold", icon: "bold", action: "bold", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_italic", icon: "italic", action: "italic", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_underline", icon: "underline", action: "underline", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_strike", icon: "strikethrough", action: "strike", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_font_size",
|
||||
icon: "type",
|
||||
needs: "editable",
|
||||
children: [9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) => ({
|
||||
labelKey: null,
|
||||
label: String(s),
|
||||
icon: null,
|
||||
action: "fontsize",
|
||||
arg: s,
|
||||
})),
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_align",
|
||||
icon: "align-justify",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{
|
||||
labelKey: "xlsx.mi_align_h",
|
||||
icon: "align-left",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_align_left", icon: "align-left", action: "align", arg: "left" },
|
||||
{ labelKey: "xlsx.format_align_center", icon: "align-center", action: "align", arg: "center" },
|
||||
{ labelKey: "xlsx.format_align_right", icon: "align-right", action: "align", arg: "right" },
|
||||
{ labelKey: "xlsx.format_align_justify", icon: "align-justify", action: "align", arg: "justify" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_align_v",
|
||||
icon: "align-center-vertical",
|
||||
children: [
|
||||
{ labelKey: "xlsx.valign_top", icon: "align-start-vertical", action: "valign", arg: "top" },
|
||||
{ labelKey: "xlsx.valign_middle", icon: "align-center-vertical", action: "valign", arg: "middle" },
|
||||
{ labelKey: "xlsx.valign_bottom", icon: "align-end-vertical", action: "valign", arg: "bottom" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_wrap",
|
||||
icon: "wrap-text",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_wrap_on", icon: "wrap-text", action: "wrap-on" },
|
||||
{ labelKey: "xlsx.mi_wrap_off", icon: "remove-formatting", action: "wrap-off" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_rotation",
|
||||
icon: "rotate-cw",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.rotation_0", icon: "minus", action: "rotate", arg: 0 },
|
||||
{ labelKey: "xlsx.rotation_45", icon: "trending-up", action: "rotate", arg: 45 },
|
||||
{ labelKey: "xlsx.rotation_315", icon: "trending-down", action: "rotate", arg: -45 },
|
||||
{ labelKey: "xlsx.rotation_90", icon: "arrow-up", action: "rotate", arg: 90 },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
// Tailles de colonnes / de lignes — même emplacement que Google Sheets
|
||||
// (Format ▸ Redimensionner) et Excel (Format ▸ Colonne / Ligne) :
|
||||
// saisie manuelle (unités Excel : caractères / points) + ajustement
|
||||
// automatique au contenu (AutoFit / « Fit to data »).
|
||||
{
|
||||
labelKey: "xlsx.mi_resize",
|
||||
icon: "move-horizontal",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_col_width", icon: "move-horizontal", action: "col-width" },
|
||||
{ labelKey: "xlsx.format_col_fit", icon: "maximize-2", action: "col-fit" },
|
||||
{ labelKey: "xlsx.format_row_height", icon: "move-vertical", action: "row-height" },
|
||||
{ labelKey: "xlsx.format_row_fit", icon: "maximize-2", action: "row-fit" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_conditional", icon: "git-compare", unavailable: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_banded",
|
||||
icon: "rows-3",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_banded_hint",
|
||||
action: "banded",
|
||||
},
|
||||
{ labelKey: "xlsx.format_clear", icon: "remove-formatting", action: "clear-format", needs: "editable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
labelKey: "xlsx.mbar_data",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_sort",
|
||||
icon: "arrow-up-down",
|
||||
children: [
|
||||
{ labelKey: "xlsx.sort_asc", icon: "arrow-up", action: "sort-asc" },
|
||||
{ labelKey: "xlsx.sort_desc", icon: "arrow-down", action: "sort-desc" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.sort_reset", icon: "rotate-ccw", action: "sort-reset" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_filter", icon: "filter", titleKey: "xlsx.mi_filter_hint", action: "filter-column" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_validation", icon: "list-checks", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_named", icon: "bookmark", action: "named-ranges" },
|
||||
{ labelKey: "xlsx.mi_protect", icon: "lock", unavailable: true },
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_cleanup",
|
||||
icon: "wand-2",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_cleanup_dupes", icon: "copy-x", action: "cleanup-dupes" },
|
||||
{ labelKey: "xlsx.mi_cleanup_trim", icon: "scissors", action: "cleanup-trim" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_split",
|
||||
icon: "columns-2",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_split_hint",
|
||||
action: "split-text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
labelKey: "xlsx.mbar_tools",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_form_create", icon: "clipboard-list", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_spellcheck", icon: "spell-check", unavailable: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_autocomplete",
|
||||
icon: "text-cursor-input",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_autocomplete_on", icon: "check", action: "autocomplete", arg: true },
|
||||
{ labelKey: "xlsx.mi_autocomplete_off", icon: "x", action: "autocomplete", arg: false },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_notifications", icon: "bell", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_macros", icon: "workflow", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_script_editor", icon: "code", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_a11y", icon: "accessibility", action: "a11y" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
labelKey: "xlsx.mbar_gemini",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_ask_gemini", icon: "sparkles", action: "gemini" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_gem_table", icon: "table-2", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_formula", icon: "function-square", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_insights", icon: "lightbulb", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_chart", icon: "bar-chart-3", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_gem_summarize", icon: "file-text", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_gem_actions", icon: "wand-sparkles", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_more", icon: "ellipsis", action: "gemini" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "extensions",
|
||||
labelKey: "xlsx.mbar_extensions",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_addons", icon: "blocks", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_apps_script", icon: "code", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_appsheet", icon: "app-window", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_looker", icon: "bar-chart", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
labelKey: "xlsx.mbar_help",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_help_sheets", icon: "life-buoy", action: "help" },
|
||||
{ labelKey: "xlsx.mi_training", icon: "graduation-cap", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_whatsnew", icon: "sparkles", action: "whatsnew" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_shortcuts", icon: "keyboard", action: "shortcuts" },
|
||||
{ labelKey: "xlsx.mi_report", icon: "bug", action: "report" },
|
||||
{ labelKey: "xlsx.mi_abuse", icon: "flag", unavailable: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Drop an entry when the workbook cannot honour it. */
|
||||
const allowed = (entry, { editable, readOnly }) => {
|
||||
if (entry.needs === "editable") return editable;
|
||||
if (entry.needs === "notReadOnly") return !readOnly;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Label of one entry: an explicit label, else the i18n key, else nothing. */
|
||||
const labelOf = (entry) => (entry.label !== undefined
|
||||
? entry.label
|
||||
: (entry.labelKey ? t(entry.labelKey) : ""));
|
||||
|
||||
/** One clickable row. `depth` > 0 = nested inside an accordion section. */
|
||||
const rowHtml = (entry, depth, ctx) => {
|
||||
if (entry.sep) return `<div class="xlsx-structure-sep" role="separator"></div>`;
|
||||
const unavailable = entry.unavailable || (!entry.children && !entry.action && !entry.export);
|
||||
const id = entry.id ? ` id="${entry.id}"` : "";
|
||||
const exportAttr = entry.export ? ` data-xlsx-export="${entry.export}"` : "";
|
||||
const argAttr = entry.arg !== undefined ? ` data-xlsx-arg="${escapeHtml(String(entry.arg))}"` : "";
|
||||
const title = entry.titleKey ? ` title="${escapeHtml(t(entry.titleKey))}"` : "";
|
||||
const cls = [
|
||||
"btn-action",
|
||||
"xlsx-structure-item",
|
||||
"xlsx-menu-item",
|
||||
unavailable ? "xlsx-menu-item-na" : "",
|
||||
entry.danger ? "xlsx-context-danger" : "",
|
||||
depth ? `xlsx-menu-item-d${depth}` : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const caret = entry.children
|
||||
? `<i data-lucide="chevron-right" class="xlsx-menu-caret" aria-hidden="true"></i>`
|
||||
: "";
|
||||
const body = entry.children
|
||||
// A section may ALSO declare an action: the viewer runs it instead of
|
||||
// toggling, which is how the empty « Thème » section gets filled.
|
||||
? `<button type="button" class="${cls}" role="menuitem" aria-haspopup="menu" aria-expanded="false"`
|
||||
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}${title}${id}>`
|
||||
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span>${caret}</button>`
|
||||
+ `<div class="xlsx-menu-sub" hidden>${entry.children
|
||||
.filter((c) => allowed(c, ctx))
|
||||
.map((c) => rowHtml(c, depth + 1, ctx))
|
||||
.join("")}</div>`
|
||||
: `<button type="button" class="${cls}" role="menuitem"`
|
||||
+ `${unavailable ? ' data-xlsx-unavailable="1"' : ""}`
|
||||
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}`
|
||||
+ `${argAttr}${exportAttr}${title}${id}>`
|
||||
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span></button>`;
|
||||
return body;
|
||||
};
|
||||
|
||||
/** One dropdown: the filtered rows of a menu. */
|
||||
const menuItemsHtml = (menu, ctx) => menu.items
|
||||
.filter((entry) => allowed(entry, ctx))
|
||||
.map((entry) => rowHtml(entry, 0, ctx))
|
||||
.join("");
|
||||
|
||||
/**
|
||||
* Build the menu bar: one button per menu plus its (hidden) dropdown.
|
||||
*
|
||||
* @param {object} ctx
|
||||
* @param {boolean} ctx.editable - .xlsx that can be written
|
||||
* @param {boolean} ctx.readOnly - .xls / .ods
|
||||
* @returns {string} the `.xlsx-menubar` markup
|
||||
*/
|
||||
export function buildMenuBar({ editable = true, readOnly = false } = {}) {
|
||||
const ctx = { editable, readOnly };
|
||||
const bars = SHEETS_MENUS.map((menu) => {
|
||||
const btnId = menu.btnId || `xlsx-menu-btn-${menu.id}`;
|
||||
const dropId = menu.dropId || `xlsx-menu-${menu.id}`;
|
||||
const label = escapeHtml(t(menu.labelKey));
|
||||
return `<span class="xlsx-menuwrap${menu.id === "gemini" ? " xlsx-menuwrap-gemini" : ""}">`
|
||||
+ `<button type="button" class="xlsx-menubtn" id="${btnId}" role="menuitem"`
|
||||
+ ` aria-haspopup="menu" aria-expanded="false" aria-controls="${dropId}"`
|
||||
+ ` data-xlsx-menu="${menu.id}">${label}</button>`
|
||||
+ `<div class="xlsx-menudrop" id="${dropId}" role="menu"`
|
||||
+ ` aria-label="${label}" style="display:none">`
|
||||
+ `${menuItemsHtml(menu, ctx)}`
|
||||
+ `</div></span>`;
|
||||
});
|
||||
return `<div class="xlsx-menubar" role="menubar" aria-label="${escapeHtml(t("xlsx.mbar_label"))}">`
|
||||
+ `${bars.join("")}</div>`;
|
||||
}
|
||||
@@ -24,9 +24,36 @@ export function columnName(col) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// #184 — cell lookup cache. `findTd` used to run a `querySelector` per call;
|
||||
// painting the grid out to A → Z × 1000 rows means a single "select column"
|
||||
// asks for 26 000 cells, which was quadratic on the DOM. The index is built
|
||||
// once per panel and rebuilt on demand whenever an entry no longer matches its
|
||||
// own `data-cell` (the signature of a renumbered grid after a structure op).
|
||||
const _cellIndex = new WeakMap();
|
||||
|
||||
function buildCellIndex(panel) {
|
||||
const map = new Map();
|
||||
panel.querySelectorAll("td[data-cell]").forEach((td) => {
|
||||
map.set(td.dataset.cell, td);
|
||||
});
|
||||
_cellIndex.set(panel, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Drop a panel's lookup cache (bulk DOM changes). */
|
||||
export function invalidateCellIndex(panel) {
|
||||
if (panel) _cellIndex.delete(panel);
|
||||
}
|
||||
|
||||
/** Find a rendered cell inside a sheet panel by (row, col). */
|
||||
export function findTd(panel, row, col) {
|
||||
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||
const ref = `${columnName(col)}${row}`;
|
||||
const cached = _cellIndex.get(panel);
|
||||
const hit = cached && cached.get(ref);
|
||||
if (hit && hit.dataset.cell === ref) return hit;
|
||||
// Miss, or a stale entry (the row was renumbered): rebuild once and retry.
|
||||
const fresh = buildCellIndex(panel).get(ref);
|
||||
return fresh && fresh.dataset.cell === ref ? fresh : null;
|
||||
}
|
||||
|
||||
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
|
||||
|
||||
Reference in New Issue
Block a user