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.
193 lines
13 KiB
JavaScript
193 lines
13 KiB
JavaScript
/* 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, fileHtml: string }}
|
|
*/
|
|
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
|
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
|
// as a real affordance; an editable workbook gets an explicit “+” button.
|
|
const tabs = isCsv
|
|
? ""
|
|
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
|
|
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
|
|
).join("")}${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 (#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>`
|
|
: "",
|
|
].filter(Boolean).join("");
|
|
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
|
|
|
|
// 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>
|
|
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
|
|
</button>
|
|
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
|
|
<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>
|
|
</span>`);
|
|
}
|
|
if (editable) {
|
|
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
|
|
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}" aria-haspopup="menu" aria-expanded="false">
|
|
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
|
|
</button>
|
|
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}" aria-haspopup="menu" aria-expanded="false">
|
|
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
|
</button>
|
|
</span>`);
|
|
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
|
|
}
|
|
// #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>
|
|
</span>`);
|
|
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
|
|
|
// #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 };
|
|
}
|