/* ObsiGate — XLSX colour picker panel (#183). * * Replaces the native `` 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>} 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 = ''; const icon = (name) => ``; 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")}${t("xlsx.colour_reset")}`; 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 }; }