diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index 39b7a0a..7001041 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -362,6 +362,15 @@ Tableaux : - bordures fines ; - lisibilite prioritaire sur la decoration. +Outil Tableau : + +- scroll horizontal autorise uniquement dans la surface de grille editable ; +- entetes de lignes/colonnes compacts et stables ; +- intitulés de lignes/colonnes éditables sans modifier les références de formule ; +- cellules en inputs directs, avec focus dore et erreurs de formule discrètes ; +- sélection multiple visible par fond cyan/violet discret et destinée aux actions de plage comme la copie TSV ; +- actions de lignes/colonnes en boutons icones. + ## 14. Toolboxes Les toolboxes sont locales et stockees dans le navigateur. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index b6fccfb..331bb6a 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -456,6 +456,43 @@ Stockage compact : - `value` est numérique. - Si un `parentId` ne pointe vers aucune entrée existante, il est réinitialisé à vide. +## Outil Tableau + +Type : `table` + +```json +{ + "rows": 12, + "columns": 8, + "cells": { + "A1": "Texte libre", + "B1": "100", + "C1": "=A1+B1", + "D1": "=(B1+20)/2" + }, + "rowLabels": { + "0": "Boss", + "1": "Phase 2" + }, + "columnLabels": { + "0": "Item", + "1": "Quantité" + } +} +``` + +Stockage compact : + +- `rows` est omis si la valeur vaut `10`. +- `columns` est omis si la valeur vaut `6`. +- `cells` conserve uniquement les cellules non vides et dans les limites du tableau. +- `rows` est limité entre `1` et `50`. +- `columns` est limité entre `1` et `20`. +- Les valeurs de cellules sont stockées sous forme de texte brut, formules incluses. +- Les résultats calculés ne sont pas persistés. +- `rowLabels` et `columnLabels` conservent uniquement les intitulés visuels personnalisés. +- Les intitulés personnalisés n'impactent pas les références de formule, qui restent basées sur les adresses `A1`, `C2`, etc. + ## Export L'export d'une toolbox remappe les identifiants pour produire des IDs courts et indépendants. diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 8e350f8..61b076b 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -176,6 +176,23 @@ export function validateSiteContent(site) { "toolboxes.modules.calculator.useEntryTitle", "toolboxes.modules.calculator.renameTitle", "toolboxes.modules.calculator.deleteTitle", + "toolboxes.modules.table.toolbarLabel", + "toolboxes.modules.table.gridLabel", + "toolboxes.modules.table.cellLabel", + "toolboxes.modules.table.rowActionsLabel", + "toolboxes.modules.table.columnActionsLabel", + "toolboxes.modules.table.sizeLabel", + "toolboxes.modules.table.addRowTitle", + "toolboxes.modules.table.removeRowTitle", + "toolboxes.modules.table.addColumnTitle", + "toolboxes.modules.table.removeColumnTitle", + "toolboxes.modules.table.copyTitle", + "toolboxes.modules.table.copiedTitle", + "toolboxes.modules.table.errorTitle", + "toolboxes.modules.table.columnLabel", + "toolboxes.modules.table.rowLabel", + "toolboxes.modules.table.columnFormulaTitle", + "toolboxes.modules.table.rowFormulaTitle", "toolboxes.modules.timer.stopwatchTab", "toolboxes.modules.timer.countdownTab", "toolboxes.modules.timer.stopwatchTitle", diff --git a/tests/static-styles.test.mjs b/tests/static-styles.test.mjs index b718d20..dbb363d 100644 --- a/tests/static-styles.test.mjs +++ b/tests/static-styles.test.mjs @@ -44,7 +44,12 @@ test("style entrypoint and theme layers are wired", async () => { assert.match(styleShell, /\.sidebar-about-link/); assert.match(styleToolboxes, /\.module-icon-abacus/); assert.match(styleToolboxes, /\.module-icon-calculator/); + assert.match(styleToolboxes, /\.module-icon-columns/); + assert.match(styleToolboxes, /\.module-icon-table/); assert.match(styleToolboxes, /\.calculator-module/); + assert.match(styleToolboxes, /\.table-module/); + assert.match(styleToolboxes, /\.table-cell-input\.is-selected/); + assert.match(styleToolboxes, /\.notepad-toolbar-button\.danger/); assert.match(styleToolboxes, /\.toolbox-icon-picker/); assert.match(styleToolboxes, /\.toolbox-game-icon/); assert.match(styleToolboxes, /\.toolbox-hero-link/); @@ -64,6 +69,11 @@ test("style entrypoint and theme layers are wired", async () => { assert.match(styleIcons, /ui-icon-chevron-up/); assert.match(styleIcons, /ui-icon-flip/); assert.match(styleIcons, /ui-icon-add/); + assert.match(styleIcons, /ui-icon-add-row/); + assert.match(styleIcons, /ui-icon-add-column/); + assert.match(styleIcons, /ui-icon-remove-row/); + assert.match(styleIcons, /ui-icon-remove-column/); + assert.match(styleIcons, /ui-icon-table/); assert.match(styleIcons, /ui-icon-map/); assert.match(styleGames, /\.game-home-card/); assert.match(styleGames, /\.game-layout/); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 6299796..ad292c2 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -36,6 +36,7 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxStorage, /export function normalizeLinksData/); assert.match(toolboxStorage, /export function normalizeCountersData/); assert.match(toolboxStorage, /export function normalizeCalculatorData/); + assert.match(toolboxStorage, /export function normalizeTableData/); assert.match(toolboxStorage, /export function normalizeTimerData/); assert.match(toolboxStorage, /export function normalizeTaskPlannerData/); assert.match(toolboxStorage, /export function normalizeImageAnnotationData/); @@ -77,6 +78,8 @@ test("toolbox module registry and modules expose expected behavior", async () => const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8"); const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); + const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8"); + const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8"); const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8"); const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8"); const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8"); @@ -98,6 +101,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(moduleRegistry, /links:/); assert.match(moduleRegistry, /counters:/); assert.match(moduleRegistry, /calculator:/); + assert.match(moduleRegistry, /table:/); + assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/); assert.match(moduleRegistry, /timer:/); assert.match(moduleRegistry, /taskPlanner:/); assert.match(moduleRegistry, /imageAnnotation:/); @@ -132,6 +137,32 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(calculatorModule, /ResizeObserver/); assert.match(calculatorModule, /--calculator-scroll-height/); assert.match(calculatorModule, /CalculatorEntries/); + assert.match(tableModule, /export function TableModule/); + assert.match(tableModule, /Icon name="add-row"/); + assert.match(tableModule, /Icon name="add-column"/); + assert.match(tableModule, /Icon name="remove-row"/); + assert.match(tableModule, /Icon name="remove-column"/); + assert.match(tableModule, /insertFormulaReferenceSelection/); + assert.match(tableModule, /startFormulaSelection/); + assert.match(tableModule, /copyTsv/); + assert.match(tableModule, /notepad-toolbar table-toolbar/); + assert.match(tableModule, /notepad-toolbar-group/); + assert.match(tableModule, /notepad-toolbar-button/); + assert.match(tableModule, /selection/); + assert.match(tableModule, /draggingSelectionRef/); + assert.match(tableModule, /isMultiCellSelection/); + assert.match(tableModule, /handleCellKeyDown/); + assert.match(tableModule, /clearSelection/); + assert.match(tableModule, /event\.key === "Delete"/); + assert.match(tableModule, /ArrowUp/); + assert.match(tableModule, /ArrowDown/); + assert.match(tableModule, /ArrowLeft/); + assert.match(tableModule, /ArrowRight/); + assert.match(tableModule, /function HeaderLabelInput/); + assert.match(tableModule, /event\.target\.select/); + assert.match(tableFormulaEngine, /export function evaluateTableCell/); + assert.doesNotMatch(tableFormulaEngine, /Function\(/); + assert.doesNotMatch(tableFormulaEngine, /\beval\(/); assert.match(timerModule, /export function TimerModule/); assert.match(timerModule, /Tabs/); assert.match(timerModule, /stopwatch/); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index fc846a3..180d63b 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -1,9 +1,10 @@ // Rôle : teste les parseurs d'import texte utilisés par les outils toolbox. import { test } from "node:test"; import assert from "node:assert/strict"; +import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; -import { compactModuleDataForStorage, createToolboxExportPayload, normalizeNotepadData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; +import { compactModuleDataForStorage, createToolboxExportPayload, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; test("colon text import keeps urls intact after the first separator", () => { assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [ @@ -28,6 +29,72 @@ test("time pattern recurrence uses configured frequency instead of next remainin assert.equal(getTimePatternRecurrenceMs("X:X:30"), 60 * 1000); }); +test("table formulas evaluate arithmetic, parentheses and cell references", () => { + const cells = { + A1: "100", + A2: "20", + B1: "=A1+A2*2", + B2: "=(A1+A2)/2", + C1: "=B1-B2/3" + }; + const dimensions = { rows: 10, columns: 6 }; + + assert.equal(evaluateTableCell("B1", cells, dimensions).display, "140"); + assert.equal(evaluateTableCell("B2", cells, dimensions).display, "60"); + assert.equal(evaluateTableCell("C1", cells, dimensions).display, "120"); +}); + +test("table formulas report invalid references, division by zero and circular references", () => { + const dimensions = { rows: 10, columns: 6 }; + + assert.equal(evaluateTableCell("A1", { A1: "=Z99+1" }, dimensions).error, "reference"); + assert.equal(evaluateTableCell("A1", { A1: "=10/0" }, dimensions).error, "division"); + assert.equal(evaluateTableCell("A1", { A1: "=B1", B1: "=A1" }, dimensions).error, "cycle"); + assert.deepEqual(evaluateTableCell("A1", { A1: "texte", B1: "=A1+1" }, dimensions), { value: null, display: "texte", error: "" }); + assert.equal(evaluateTableCell("B1", { A1: "texte", B1: "=A1+1" }, dimensions).error, "reference"); +}); + +test("table storage clamps dimensions and compacts non-empty cells", () => { + const normalized = normalizeTableData({ + rows: 200, + columns: 40, + cells: { + A1: "Boss", + T50: "=A1+1", + U51: "Ignored", + B1: " " + }, + rowLabels: { + 0: "Boss final ", + 1: "2", + 50: "Ignored" + }, + columnLabels: { + 0: "Item final ", + 1: "B", + 20: "Ignored" + } + }); + + assert.equal(normalized.rows, 50); + assert.equal(normalized.columns, 20); + assert.deepEqual(normalized.cells, { A1: "Boss", T50: "=A1+1" }); + assert.deepEqual(normalized.rowLabels, { 0: "Boss final " }); + assert.deepEqual(normalized.columnLabels, { 0: "Item final " }); + + const compact = compactModuleDataForStorage("table", normalized); + assert.equal(compact.rows, 50); + assert.equal(compact.columns, 20); + assert.deepEqual(compact.cells, { A1: "Boss", T50: "=A1+1" }); + assert.deepEqual(compact.rowLabels, { 0: "Boss final " }); + assert.deepEqual(compact.columnLabels, { 0: "Item final " }); + assert.deepEqual(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {}, columnLabels: { 0: "Item " } }), { + cells: {}, + columnLabels: { 0: "Item " } + }); + assert.equal(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {} }), null); +}); + test("notepad data migrates text, sanitizes html and compacts drawings", () => { const migrated = normalizeNotepadData({ text: "Boss\nPhase 2" }); assert.equal(migrated.text, "Boss\nPhase 2"); diff --git a/website/public/data/site.json b/website/public/data/site.json index 3730639..f029542 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -307,6 +307,25 @@ "renameTitle": "Renommer", "deleteTitle": "Supprimer" }, + "table": { + "toolbarLabel": "Actions du tableau", + "gridLabel": "Tableau", + "cellLabel": "Cellule", + "rowActionsLabel": "Lignes", + "columnActionsLabel": "Colonnes", + "sizeLabel": "Taille du tableau", + "addRowTitle": "Ajouter une ligne", + "removeRowTitle": "Supprimer la dernière ligne", + "addColumnTitle": "Ajouter une colonne", + "removeColumnTitle": "Supprimer la dernière colonne", + "copyTitle": "Copier en TSV", + "copiedTitle": "Tableau copié", + "errorTitle": "Formule invalide", + "columnLabel": "Intitulé de colonne", + "rowLabel": "Intitulé de ligne", + "columnFormulaTitle": "Référence conservée dans les formules", + "rowFormulaTitle": "Référence conservée dans les formules" + }, "timer": { "stopwatchTab": "Chronomètre", "countdownTab": "Compte à rebours", diff --git a/website/public/static/icons/add-column.svg b/website/public/static/icons/add-column.svg new file mode 100644 index 0000000..72cb047 --- /dev/null +++ b/website/public/static/icons/add-column.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/add-row.svg b/website/public/static/icons/add-row.svg new file mode 100644 index 0000000..6fb7bc6 --- /dev/null +++ b/website/public/static/icons/add-row.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/remove-column.svg b/website/public/static/icons/remove-column.svg new file mode 100644 index 0000000..f1547c9 --- /dev/null +++ b/website/public/static/icons/remove-column.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/remove-row.svg b/website/public/static/icons/remove-row.svg new file mode 100644 index 0000000..356b164 --- /dev/null +++ b/website/public/static/icons/remove-row.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/website/public/static/icons/table.svg b/website/public/static/icons/table.svg new file mode 100644 index 0000000..3eb1cd6 --- /dev/null +++ b/website/public/static/icons/table.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index c0b3822..a5815c6 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -18,6 +18,7 @@ import { normalizeImageAnnotationData, normalizeLinksData, normalizeNotepadData, + normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeUrl, @@ -148,6 +149,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto normalizeCalculatorData, normalizeImageAnnotationData, normalizeNotepadData, + normalizeTableData, normalizeTimerData, normalizeTaskPlannerData, normalizeUrl, diff --git a/website/src/features/toolboxes/modules/TableModule.jsx b/website/src/features/toolboxes/modules/TableModule.jsx new file mode 100644 index 0000000..fc689d0 --- /dev/null +++ b/website/src/features/toolboxes/modules/TableModule.jsx @@ -0,0 +1,642 @@ +// Rôle : fournit l'outil tableau avec cellules libres et formules simples. +import { useEffect, useMemo, useRef, useState } from "react"; +import { Icon } from "../../../components/Icon.jsx"; +import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js"; + +const DEFAULT_ROWS = 10; +const DEFAULT_COLUMNS = 6; +const MAX_ROWS = 50; +const MAX_COLUMNS = 20; +const MIN_COLUMN_WIDTH = 96; +const MAX_COLUMN_WIDTH = 220; +const MIN_ROW_HEADER_WIDTH = 42; +const MAX_ROW_HEADER_WIDTH = 180; + +function cleanCells(cells, rows, columns) { + const nextCells = {}; + for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { + for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) { + const address = cellAddress(rowIndex, columnIndex); + const value = String(cells?.[address] || ""); + if (value.trim()) nextCells[address] = value; + } + } + return nextCells; +} + +function cleanLabels(labels, length, getFallback) { + const nextLabels = {}; + for (let index = 0; index < length; index += 1) { + const value = String(labels?.[index] || "").slice(0, 80); + if (value.trim() && value !== String(getFallback(index))) nextLabels[index] = value; + } + return nextLabels; +} + +function estimateTextWidth(value, minWidth, maxWidth) { + const length = String(value || "").length; + return Math.min(maxWidth, Math.max(minWidth, 24 + length * 8)); +} + +function getColumnWidths(data, evaluatedCells) { + return Array.from({ length: data.columns }, (_, columnIndex) => { + let width = estimateTextWidth(data.columnLabels?.[columnIndex] || columnIndexToName(columnIndex), MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH); + for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) { + const address = cellAddress(rowIndex, columnIndex); + const value = evaluatedCells[address]?.display || data.cells[address] || ""; + width = Math.max(width, estimateTextWidth(value, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)); + } + return width; + }); +} + +function getRowHeaderWidth(data) { + return Array.from({ length: data.rows }, (_, rowIndex) => data.rowLabels?.[rowIndex] || rowIndex + 1) + .reduce((width, label) => Math.max(width, estimateTextWidth(label, MIN_ROW_HEADER_WIDTH, MAX_ROW_HEADER_WIDTH)), MIN_ROW_HEADER_WIDTH); +} + +function getSelectionBounds(selection) { + if (!selection?.anchor || !selection?.focus) return null; + return { + startRow: Math.min(selection.anchor.rowIndex, selection.focus.rowIndex), + endRow: Math.max(selection.anchor.rowIndex, selection.focus.rowIndex), + startColumn: Math.min(selection.anchor.columnIndex, selection.focus.columnIndex), + endColumn: Math.max(selection.anchor.columnIndex, selection.focus.columnIndex) + }; +} + +function isCellSelected(selection, rowIndex, columnIndex) { + const bounds = getSelectionBounds(selection); + return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn); +} + +function isMultiCellSelection(selection) { + const bounds = getSelectionBounds(selection); + return Boolean(bounds && (bounds.startRow !== bounds.endRow || bounds.startColumn !== bounds.endColumn)); +} + +function clampCellPosition(rowIndex, columnIndex, rows, columns) { + return { + rowIndex: Math.min(rows - 1, Math.max(0, rowIndex)), + columnIndex: Math.min(columns - 1, Math.max(0, columnIndex)) + }; +} + +function getRangeAddresses(selection) { + const bounds = getSelectionBounds(selection); + if (!bounds) return []; + const addresses = []; + for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) { + for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) { + addresses.push(cellAddress(rowIndex, columnIndex)); + } + } + return addresses; +} + +function getFormulaRangeText(selection) { + const addresses = getRangeAddresses(selection); + if (!addresses.length) return ""; + return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`; +} + +export function TableModule({ toolboxId, moduleId, context }) { + const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} })); + const textContent = context.moduleText?.table || {}; + const [editingCell, setEditingCell] = useState(""); + const [highlightedCell, setHighlightedCell] = useState(""); + const [selection, setSelection] = useState(null); + const draggingSelectionRef = useRef(false); + const formulaSelectionRef = useRef(null); + const inputRefs = useRef(new Map()); + const highlightTimeoutRef = useRef(0); + const evaluatedCells = useMemo(() => evaluateTable(data.cells, data), [data]); + const columnWidths = useMemo(() => getColumnWidths(data, evaluatedCells), [data, evaluatedCells]); + const rowHeaderWidth = useMemo(() => getRowHeaderWidth(data), [data]); + + useEffect(() => { + function stopSelectionDrag() { + if (formulaSelectionRef.current) { + const formulaSelection = formulaSelectionRef.current; + formulaSelectionRef.current = null; + draggingSelectionRef.current = false; + insertFormulaReferenceSelection(formulaSelection); + return; + } + draggingSelectionRef.current = false; + } + + window.addEventListener("pointerup", stopSelectionDrag); + return () => window.removeEventListener("pointerup", stopSelectionDrag); + }); + + function save(nextData) { + context.setModuleData(toolboxId, moduleId, nextData, "table"); + } + + function setCellValue(address, value) { + const nextCells = { ...data.cells }; + if (String(value || "").trim()) { + nextCells[address] = value; + } else { + delete nextCells[address]; + } + save({ ...data, cells: nextCells }); + } + + function clearSelection(rowIndex, columnIndex) { + const nextCells = { ...data.cells }; + const bounds = getSelectionBounds(selection) || { + startRow: rowIndex, + endRow: rowIndex, + startColumn: columnIndex, + endColumn: columnIndex + }; + + for (let selectedRowIndex = bounds.startRow; selectedRowIndex <= bounds.endRow; selectedRowIndex += 1) { + for (let selectedColumnIndex = bounds.startColumn; selectedColumnIndex <= bounds.endColumn; selectedColumnIndex += 1) { + delete nextCells[cellAddress(selectedRowIndex, selectedColumnIndex)]; + } + } + + save({ ...data, cells: nextCells }); + const focusedAddress = cellAddress(rowIndex, columnIndex); + window.requestAnimationFrame(() => inputRefs.current.get(focusedAddress)?.focus()); + } + + function resize(rows, columns) { + save({ + rows, + columns, + cells: cleanCells(data.cells, rows, columns), + rowLabels: cleanLabels(data.rowLabels, rows, (rowIndex) => rowIndex + 1), + columnLabels: cleanLabels(data.columnLabels, columns, columnIndexToName) + }); + } + + function setColumnLabel(columnIndex, value) { + const nextLabels = { ...data.columnLabels }; + const nextValue = String(value || "").slice(0, 80); + if (nextValue.trim() && nextValue !== columnIndexToName(columnIndex)) { + nextLabels[columnIndex] = nextValue; + } else { + delete nextLabels[columnIndex]; + } + save({ ...data, columnLabels: nextLabels }); + } + + function setRowLabel(rowIndex, value) { + const nextLabels = { ...data.rowLabels }; + const nextValue = String(value || "").slice(0, 80); + if (nextValue.trim() && nextValue !== String(rowIndex + 1)) { + nextLabels[rowIndex] = nextValue; + } else { + delete nextLabels[rowIndex]; + } + save({ ...data, rowLabels: nextLabels }); + } + + function registerInput(address, element) { + if (element) { + inputRefs.current.set(address, element); + } else { + inputRefs.current.delete(address); + } + } + + function getActiveFormulaInput() { + if (!editingCell) return null; + const input = inputRefs.current.get(editingCell); + const value = data.cells[editingCell] || ""; + return input && value.startsWith("=") ? input : null; + } + + function focusCell(rowIndex, columnIndex, options = {}) { + const position = clampCellPosition(rowIndex, columnIndex, data.rows, data.columns); + const address = cellAddress(position.rowIndex, position.columnIndex); + window.requestAnimationFrame(() => { + const input = inputRefs.current.get(address); + input?.focus(); + if (options.select) { + input?.select(); + } else { + input?.setSelectionRange(input.value.length, input.value.length); + } + }); + } + + function startEditing(address, initialValue = null, options = {}) { + const nextValue = initialValue == null ? data.cells[address] || "" : initialValue; + if (initialValue != null) setCellValue(address, initialValue); + setEditingCell(address); + window.requestAnimationFrame(() => { + const input = inputRefs.current.get(address); + input?.focus(); + if (options.select) { + input?.select(); + } else { + const position = options.caret ?? nextValue.length; + input?.setSelectionRange(position, position); + } + }); + } + + function stopEditing(address) { + setEditingCell(""); + const parsed = parseCellAddress(address); + if (parsed) focusCell(parsed.rowIndex, parsed.columnIndex); + } + + function insertFormulaText(text, options = {}) { + const input = getActiveFormulaInput(); + if (!input || !text) return false; + + const currentValue = data.cells[editingCell] || ""; + const start = options.start ?? input.selectionStart ?? currentValue.length; + const end = options.end ?? input.selectionEnd ?? start; + const nextValue = `${currentValue.slice(0, start)}${text}${currentValue.slice(end)}`; + setCellValue(editingCell, nextValue); + setHighlightedCell(options.highlightedCell || text); + window.clearTimeout(highlightTimeoutRef.current); + highlightTimeoutRef.current = window.setTimeout(() => setHighlightedCell(""), 700); + window.requestAnimationFrame(() => { + input.focus(); + input.setSelectionRange(start + text.length, start + text.length); + }); + return true; + } + + function insertFormulaReferenceSelection(formulaSelection) { + const text = getFormulaRangeText({ anchor: formulaSelection.anchor, focus: formulaSelection.focus }); + insertFormulaText(text, { + start: formulaSelection.start, + end: formulaSelection.end, + highlightedCell: cellAddress(formulaSelection.focus.rowIndex, formulaSelection.focus.columnIndex) + }); + } + + function getTsv(bounds = null) { + const rows = []; + const startRow = bounds?.startRow ?? 0; + const endRow = bounds?.endRow ?? data.rows - 1; + const startColumn = bounds?.startColumn ?? 0; + const endColumn = bounds?.endColumn ?? data.columns - 1; + + for (let rowIndex = startRow; rowIndex <= endRow; rowIndex += 1) { + const columns = []; + for (let columnIndex = startColumn; columnIndex <= endColumn; columnIndex += 1) { + columns.push(evaluatedCells[cellAddress(rowIndex, columnIndex)]?.display || ""); + } + rows.push(columns.join("\t")); + } + return rows.join("\n"); + } + + async function copyTsv() { + const text = getTsv(isMultiCellSelection(selection) ? getSelectionBounds(selection) : null); + if (!await context.copyText(text)) return; + context.notify?.(textContent.copiedTitle || "Tableau copié"); + } + + async function copySelection(event) { + if (!selection || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "c") return; + event.preventDefault(); + const text = getTsv(getSelectionBounds(selection)); + if (!await context.copyText(text)) return; + context.notify?.(textContent.copiedTitle || "Tableau copié"); + } + + function selectCell(rowIndex, columnIndex, extend = false) { + const cell = { rowIndex, columnIndex }; + setSelection((currentSelection) => extend && currentSelection?.anchor + ? { anchor: currentSelection.anchor, focus: cell } + : { anchor: cell, focus: cell }); + } + + function extendSelection(rowIndex, columnIndex) { + const cell = { rowIndex, columnIndex }; + setSelection((currentSelection) => currentSelection?.anchor + ? { anchor: currentSelection.anchor, focus: cell } + : { anchor: cell, focus: cell }); + } + + function startFormulaSelection(rowIndex, columnIndex) { + const input = getActiveFormulaInput(); + const formulaCell = parseCellAddress(editingCell); + if (!input || (formulaCell?.rowIndex === rowIndex && formulaCell?.columnIndex === columnIndex)) return false; + const cell = { rowIndex, columnIndex }; + formulaSelectionRef.current = { + anchor: cell, + focus: cell, + start: input.selectionStart ?? (data.cells[editingCell] || "").length, + end: input.selectionEnd ?? input.selectionStart ?? (data.cells[editingCell] || "").length + }; + setSelection({ anchor: cell, focus: cell }); + draggingSelectionRef.current = true; + return true; + } + + function extendFormulaSelection(rowIndex, columnIndex) { + if (!formulaSelectionRef.current) return false; + const focus = { rowIndex, columnIndex }; + formulaSelectionRef.current = { ...formulaSelectionRef.current, focus }; + setSelection({ anchor: formulaSelectionRef.current.anchor, focus }); + return true; + } + + function handleCellKeyDown(event, rowIndex, columnIndex, address, isEditing) { + if (!isEditing && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") { + copySelection(event); + return; + } + + if (!isEditing && event.key === "Delete") { + event.preventDefault(); + clearSelection(rowIndex, columnIndex); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + formulaSelectionRef.current = null; + draggingSelectionRef.current = false; + setEditingCell(""); + setSelection({ anchor: { rowIndex, columnIndex }, focus: { rowIndex, columnIndex } }); + focusCell(rowIndex, columnIndex); + return; + } + + if (!isEditing && event.key === "Enter") { + event.preventDefault(); + startEditing(address, null, { select: true }); + return; + } + + const deltas = { + ArrowUp: [-1, 0], + ArrowDown: [1, 0], + ArrowLeft: [0, -1], + ArrowRight: [0, 1], + Enter: [event.shiftKey ? -1 : 1, 0], + Tab: [0, event.shiftKey ? -1 : 1] + }; + const delta = deltas[event.key]; + if (!delta) { + if (!isEditing && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + startEditing(address, event.key, { caret: event.key.length }); + } + return; + } + + const input = event.currentTarget; + const valueLength = input.value.length; + const caretStart = input.selectionStart ?? valueLength; + const caretEnd = input.selectionEnd ?? caretStart; + const hasTextSelection = caretStart !== caretEnd; + const isHorizontalArrow = event.key === "ArrowLeft" || event.key === "ArrowRight"; + const canMoveHorizontal = event.key === "ArrowLeft" ? caretStart === 0 : caretEnd === valueLength; + const shouldNavigate = !isEditing || event.key === "Tab" || event.key === "Enter" || event.shiftKey || !isHorizontalArrow || (!hasTextSelection && canMoveHorizontal); + + if (!shouldNavigate) return; + + event.preventDefault(); + setEditingCell(""); + const nextCell = clampCellPosition(rowIndex + delta[0], columnIndex + delta[1], data.rows, data.columns); + if (event.shiftKey && event.key.startsWith("Arrow")) { + extendSelection(nextCell.rowIndex, nextCell.columnIndex); + } else { + selectCell(nextCell.rowIndex, nextCell.columnIndex); + } + focusCell(nextCell.rowIndex, nextCell.columnIndex); + } + + return ( +