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.
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
/* 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 };
|
|
} |