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 ( +
+
+
+
+ + +
+
+ + +
+
+ {data.rows} x {data.columns} +
+
+
+ +
+
+
+
`minmax(${width}px, 1fr)`).join(" ")}`, + "--table-min-width": `${rowHeaderWidth + columnWidths.reduce((total, width) => total + width, 0)}px` + }} + role="grid" + aria-label={textContent.gridLabel || "Tableau"} + > +
+
+
+ ); +} + +function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onSelectCell, onExtendSelection, draggingSelectionRef, onCellKeyDown, onStartEditing, registerInput }) { + return ( + <> + onRowLabelChange(rowIndex, value)} + aria-label={`${textContent.rowLabel || "Intitulé de ligne"} ${rowIndex + 1}`} + title={`${rowIndex + 1} - ${textContent.rowFormulaTitle || "Référence conservée dans les formules"}`} + /> + {Array.from({ length: columns }, (_, columnIndex) => { + const address = cellAddress(rowIndex, columnIndex); + const rawValue = cells[address] || ""; + const evaluated = evaluatedCells[address]; + const isEditing = editingCell === address; + const isFormula = rawValue.startsWith("="); + const displayValue = isEditing ? rawValue : evaluated?.display || rawValue; + const className = [ + "table-cell-input", + isEditing ? "is-editing" : "", + isFormula ? "is-formula" : "", + evaluated?.error && evaluated.error !== "empty" && !isEditing ? "has-error" : "", + isCellSelected(selection, rowIndex, columnIndex) ? "is-selected" : "", + highlightedCell === address ? "is-referenced" : "" + ].filter(Boolean).join(" "); + + return ( + registerInput(address, element)} + className={className} + value={displayValue} + onMouseDown={(event) => { + if (onFormulaSelectionStart(rowIndex, columnIndex)) { + event.preventDefault(); + } else { + draggingSelectionRef.current = true; + onSelectCell(rowIndex, columnIndex, event.shiftKey); + } + }} + onDoubleClick={() => onStartEditing(address, null, { select: true })} + onMouseEnter={() => { + if (onFormulaSelectionExtend(rowIndex, columnIndex)) return; + if (draggingSelectionRef.current) onExtendSelection(rowIndex, columnIndex); + }} + onFocus={() => onFocus(address)} + onBlur={() => onBlur(address)} + onChange={(event) => onCellChange(address, event.target.value)} + onKeyDown={(event) => onCellKeyDown(event, rowIndex, columnIndex, address, isEditing)} + aria-label={`${textContent.cellLabel || "Cellule"} ${address}`} + title={evaluated?.error && evaluated.error !== "empty" ? textContent.errorTitle || "Formule invalide" : address} + inputMode="text" + readOnly={!isEditing} + spellCheck="false" + /> + ); + })} + + ); +} + +function HeaderLabelInput({ className, value, fallback, onSave, ...props }) { + const [draft, setDraft] = useState(value); + const [focused, setFocused] = useState(false); + const skipSaveRef = useRef(false); + + useEffect(() => { + if (!focused) setDraft(value); + }, [focused, value]); + + function saveDraft() { + setFocused(false); + if (skipSaveRef.current) { + skipSaveRef.current = false; + setDraft(value); + return; + } + onSave(draft); + } + + return ( + { + setFocused(true); + setDraft(value); + if (value === fallback) window.requestAnimationFrame(() => event.target.select()); + }} + onChange={(event) => setDraft(event.target.value)} + onBlur={saveDraft} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + skipSaveRef.current = true; + setDraft(value); + setFocused(false); + event.currentTarget.blur(); + } + }} + spellCheck="false" + /> + ); +} diff --git a/website/src/features/toolboxes/modules/index.jsx b/website/src/features/toolboxes/modules/index.jsx index b5c040c..316a497 100644 --- a/website/src/features/toolboxes/modules/index.jsx +++ b/website/src/features/toolboxes/modules/index.jsx @@ -12,6 +12,7 @@ import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx"; import { LinksModule } from "./LinksModule.jsx"; import { NotepadModule } from "./NotepadModule.jsx"; import { ImagesModule } from "./ImagesModule.jsx"; +import { TableModule } from "./TableModule.jsx"; import { TaskPlannerModule } from "./TaskPlannerModule.jsx"; import { TimerModule } from "./TimerModule.jsx"; @@ -22,6 +23,7 @@ const MODULE_COMPONENTS = { links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true }, counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true }, calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false }, + table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true }, timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false }, taskPlanner: { label: "Planificateur de tâches", icon: "tasklist", Component: TaskPlannerModule, editable: true, scrollable: true }, imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true } diff --git a/website/src/features/toolboxes/modules/tableFormulaEngine.js b/website/src/features/toolboxes/modules/tableFormulaEngine.js new file mode 100644 index 0000000..e3e98b1 --- /dev/null +++ b/website/src/features/toolboxes/modules/tableFormulaEngine.js @@ -0,0 +1,201 @@ +// Rôle : analyse et évalue les formules limitées de l'outil tableau. +const CELL_REFERENCE_RE = /^[A-Z]+[1-9]\d*$/; + +export function columnIndexToName(index) { + let value = Math.max(0, Number(index) || 0) + 1; + let name = ""; + while (value > 0) { + const remainder = (value - 1) % 26; + name = String.fromCharCode(65 + remainder) + name; + value = Math.floor((value - 1) / 26); + } + return name; +} + +export function cellAddress(rowIndex, columnIndex) { + return `${columnIndexToName(columnIndex)}${rowIndex + 1}`; +} + +export function parseCellAddress(address) { + const match = String(address || "").toUpperCase().match(/^([A-Z]+)([1-9]\d*)$/); + if (!match) return null; + const columnName = match[1]; + let columnIndex = 0; + for (const char of columnName) { + columnIndex = columnIndex * 26 + char.charCodeAt(0) - 64; + } + return { rowIndex: Number(match[2]) - 1, columnIndex: columnIndex - 1 }; +} + +export function isCellInBounds(address, rows, columns) { + const parsed = parseCellAddress(address); + return Boolean(parsed && parsed.rowIndex >= 0 && parsed.rowIndex < rows && parsed.columnIndex >= 0 && parsed.columnIndex < columns); +} + +function parsePlainNumber(value) { + const normalized = String(value || "").trim().replace(",", "."); + if (!normalized || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return null; + const number = Number(normalized); + return Number.isFinite(number) ? number : null; +} + +function formatNumber(value) { + if (!Number.isFinite(value)) return ""; + return Number.parseFloat(value.toFixed(8)).toString(); +} + +function tokenize(expression) { + const tokens = []; + let index = 0; + + while (index < expression.length) { + const char = expression[index]; + if (/\s/.test(char)) { + index += 1; + continue; + } + if ("+-*/()".includes(char)) { + tokens.push({ type: char, value: char }); + index += 1; + continue; + } + + const numberMatch = expression.slice(index).match(/^(?:\d+(?:[\.,]\d*)?|[\.,]\d+)/); + if (numberMatch) { + const value = Number(numberMatch[0].replace(",", ".")); + if (!Number.isFinite(value)) throw new Error("invalid"); + tokens.push({ type: "number", value }); + index += numberMatch[0].length; + continue; + } + + const referenceMatch = expression.slice(index).toUpperCase().match(/^[A-Z]+[1-9]\d*/); + if (referenceMatch) { + tokens.push({ type: "reference", value: referenceMatch[0] }); + index += referenceMatch[0].length; + continue; + } + + throw new Error("invalid"); + } + + return tokens; +} + +function createParser(tokens, resolveReference) { + let index = 0; + + function current() { + return tokens[index]; + } + + function consume(type) { + if (current()?.type !== type) return false; + index += 1; + return true; + } + + function parsePrimary() { + const token = current(); + if (!token) throw new Error("invalid"); + if (consume("number")) return token.value; + if (consume("reference")) return resolveReference(token.value); + if (consume("(")) { + const value = parseExpression(); + if (!consume(")")) throw new Error("invalid"); + return value; + } + throw new Error("invalid"); + } + + function parseUnary() { + if (consume("+")) return parseUnary(); + if (consume("-")) return -parseUnary(); + return parsePrimary(); + } + + function parseTerm() { + let value = parseUnary(); + while (current()?.type === "*" || current()?.type === "/") { + const operator = current().type; + index += 1; + const right = parseUnary(); + if (operator === "*") { + value *= right; + } else { + if (right === 0) throw new Error("division"); + value /= right; + } + } + return value; + } + + function parseExpression() { + let value = parseTerm(); + while (current()?.type === "+" || current()?.type === "-") { + const operator = current().type; + index += 1; + const right = parseTerm(); + value = operator === "+" ? value + right : value - right; + } + return value; + } + + function parse() { + const value = parseExpression(); + if (index < tokens.length) throw new Error("invalid"); + return value; + } + + return { parse }; +} + +export function evaluateFormula(expression, resolveReference) { + try { + const tokens = tokenize(String(expression || "")); + if (!tokens.length) return { value: null, error: "invalid" }; + const value = createParser(tokens, resolveReference).parse(); + return Number.isFinite(value) ? { value, error: "" } : { value: null, error: "invalid" }; + } catch (error) { + return { value: null, error: error.message === "division" ? "division" : error.message || "invalid" }; + } +} + +export function evaluateTableCell(address, cells, dimensions, stack = []) { + const raw = String(cells?.[address] || "").trim(); + if (!raw) return { value: null, display: "", error: "empty" }; + + if (stack.includes(address)) return { value: null, display: "#CYCLE", error: "cycle" }; + + if (!raw.startsWith("=")) { + const value = parsePlainNumber(raw); + return value == null + ? { value: null, display: raw, error: "" } + : { value, display: formatNumber(value), error: "" }; + } + + const result = evaluateFormula(raw.slice(1), (reference) => { + if (!CELL_REFERENCE_RE.test(reference) || !isCellInBounds(reference, dimensions.rows, dimensions.columns)) throw new Error("reference"); + const value = evaluateTableCell(reference, cells, dimensions, [...stack, address]); + if (value.error || value.value == null) throw new Error(value.error === "cycle" ? "cycle" : "reference"); + return value.value; + }); + + if (result.error) { + const label = result.error === "cycle" ? "#CYCLE" : result.error === "division" ? "#DIV/0" : "#ERREUR"; + return { value: null, display: label, error: result.error }; + } + + return { value: result.value, display: formatNumber(result.value), error: "" }; +} + +export function evaluateTable(cells, dimensions) { + const evaluated = {}; + for (let rowIndex = 0; rowIndex < dimensions.rows; rowIndex += 1) { + for (let columnIndex = 0; columnIndex < dimensions.columns; columnIndex += 1) { + const address = cellAddress(rowIndex, columnIndex); + evaluated[address] = evaluateTableCell(address, cells, dimensions); + } + } + return evaluated; +} diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js index 608460f..f0b07e3 100644 --- a/website/src/features/toolboxes/storage/toolboxStorage.js +++ b/website/src/features/toolboxes/storage/toolboxStorage.js @@ -48,6 +48,7 @@ const DEFAULT_MODULE_TITLES = { links: "Liens", counters: "Compteurs", calculator: "Calculateur", + table: "Tableau", timer: "Timer", taskPlanner: "Planificateur de tâches", imageAnnotation: "Annotation d'images" @@ -360,6 +361,36 @@ function normalizeCalculatorValue(value) { return Number.isFinite(parsed) ? parsed : 0; } +function clampTableSize(value, fallback, max) { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) ? Math.min(max, Math.max(1, parsed)) : fallback; +} + +function columnIndexToName(index) { + let value = Math.max(0, Number(index) || 0) + 1; + let name = ""; + while (value > 0) { + const remainder = (value - 1) % 26; + name = String.fromCharCode(65 + remainder) + name; + value = Math.floor((value - 1) / 26); + } + return name; +} + +function tableCellAddress(rowIndex, columnIndex) { + return `${columnIndexToName(columnIndex)}${rowIndex + 1}`; +} + +function normalizeTableLabels(labels, length, getFallback) { + const sourceLabels = labels && typeof labels === "object" ? labels : {}; + const normalized = {}; + for (let index = 0; index < length; index += 1) { + const value = String(sourceLabels[index] || "").slice(0, 80); + if (value.trim() && value !== String(getFallback(index))) normalized[index] = value; + } + return normalized; +} + function isTimeString(value, allowWildcard = false) { const pattern = allowWildcard ? /^(?:\d{2}|X):(?:\d{2}|X):(?:\d{2}|X)$/ : /^\d{2}:\d{2}:\d{2}$/; if (!pattern.test(String(value || ""))) return false; @@ -406,6 +437,29 @@ export function normalizeCalculatorData(data) { }; } +export function normalizeTableData(data) { + const rows = clampTableSize(data?.rows, 10, 50); + const columns = clampTableSize(data?.columns, 6, 20); + const sourceCells = data?.cells && typeof data.cells === "object" ? data.cells : {}; + const cells = {}; + + for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { + for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) { + const address = tableCellAddress(rowIndex, columnIndex); + const value = String(sourceCells[address] || "").trim(); + if (value) cells[address] = value.slice(0, 500); + } + } + + return { + rows, + columns, + cells, + rowLabels: normalizeTableLabels(data?.rowLabels, rows, (rowIndex) => rowIndex + 1), + columnLabels: normalizeTableLabels(data?.columnLabels, columns, columnIndexToName) + }; +} + export function normalizeTimerData(data) { const stopwatch = data?.stopwatch || {}; const laps = (Array.isArray(stopwatch.laps) ? stopwatch.laps : []) @@ -657,6 +711,15 @@ export function compactModuleDataForStorage(type, value) { if (!entries.length && !normalized.scrollResults) return null; return normalized.scrollResults ? { entries, scrollResults: true } : { entries }; } + if (type === "table") { + const normalized = normalizeTableData(value); + const compact = { cells: normalized.cells }; + if (normalized.rows !== 10) compact.rows = normalized.rows; + if (normalized.columns !== 6) compact.columns = normalized.columns; + if (Object.keys(normalized.rowLabels).length) compact.rowLabels = normalized.rowLabels; + if (Object.keys(normalized.columnLabels).length) compact.columnLabels = normalized.columnLabels; + return Object.keys(compact.cells).length || compact.rows || compact.columns || compact.rowLabels || compact.columnLabels ? compact : null; + } if (type === "timer") { const normalized = normalizeTimerData(value); const compact = {}; diff --git a/website/src/styles/_icons.scss b/website/src/styles/_icons.scss index a68c989..09ee87d 100644 --- a/website/src/styles/_icons.scss +++ b/website/src/styles/_icons.scss @@ -207,6 +207,31 @@ -webkit-mask-image: url("/static/icons/columns.svg"); } +.ui-icon-add-row { + mask-image: url("/static/icons/add-row.svg"); + -webkit-mask-image: url("/static/icons/add-row.svg"); +} + +.ui-icon-add-column { + mask-image: url("/static/icons/add-column.svg"); + -webkit-mask-image: url("/static/icons/add-column.svg"); +} + +.ui-icon-remove-row { + mask-image: url("/static/icons/remove-row.svg"); + -webkit-mask-image: url("/static/icons/remove-row.svg"); +} + +.ui-icon-remove-column { + mask-image: url("/static/icons/remove-column.svg"); + -webkit-mask-image: url("/static/icons/remove-column.svg"); +} + +.ui-icon-table { + mask-image: url("/static/icons/table.svg"); + -webkit-mask-image: url("/static/icons/table.svg"); +} + .ui-icon-close { mask-image: url("/static/icons/close.svg"); -webkit-mask-image: url("/static/icons/close.svg"); diff --git a/website/src/styles/_toolboxes.scss b/website/src/styles/_toolboxes.scss index 3782815..24be7ec 100644 --- a/website/src/styles/_toolboxes.scss +++ b/website/src/styles/_toolboxes.scss @@ -732,6 +732,16 @@ -webkit-mask-image: url("/static/icons/map.svg"); } +.module-icon-columns { + mask-image: url("/static/icons/columns.svg"); + -webkit-mask-image: url("/static/icons/columns.svg"); +} + +.module-icon-table { + mask-image: url("/static/icons/table.svg"); + -webkit-mask-image: url("/static/icons/table.svg"); +} + .tabs { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); @@ -2757,6 +2767,191 @@ textarea:focus { text-align: right; } +.table-module { + display: grid; + gap: var(--space-3); + min-width: 0; + padding: var(--space-4); +} + +.table-toolbar { + justify-content: space-between; +} + +.table-controls { + flex: 1 1 auto; +} + +.table-size-indicator { + min-height: 34px; + display: inline-flex; + align-items: center; + padding: 0 8px; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: 800; +} + +.table-copy-group { + margin-left: auto; +} + +.table-copy-group.notepad-toolbar-group { + padding-right: 0; + border-right: 0; +} + +.table-grid-wrap { + width: 100%; + min-width: 0; + overflow: auto; + border: 1px solid rgba(165, 180, 252, 0.12); + border-radius: var(--radius-md); + background: + linear-gradient(135deg, rgba(139, 92, 246, 0.045), transparent 62%), + rgba(7, 10, 24, 0.24); +} + +.table-grid { + display: grid; + grid-template-columns: var(--table-columns); + width: 100%; + min-width: var(--table-min-width); + align-items: stretch; + justify-content: start; +} + +.table-grid > * { + min-width: 0; +} + +.table-corner, +.table-header-cell, +.table-row-header { + display: grid; + min-height: 34px; + place-items: center; + border-right: 1px solid rgba(165, 180, 252, 0.1); + border-bottom: 1px solid rgba(165, 180, 252, 0.1); + background: rgba(5, 7, 17, 0.54); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: 900; + font-variant-numeric: tabular-nums; +} + +.table-header-input { + width: 100%; + padding: 0 8px; + border-top: 0; + border-left: 0; + border-radius: 0; + text-align: center; +} + +.table-header-input::placeholder { + color: var(--color-text-muted); + opacity: 1; +} + +.table-header-input:hover { + background: rgba(21, 26, 48, 0.68); +} + +.table-header-input:focus { + z-index: 5; + border-color: rgba(246, 196, 83, 0.38); + background: rgba(21, 26, 48, 0.8); + color: var(--color-text-primary); + box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.55), 0 0 0 2px rgba(139, 92, 246, 0.18); + outline: 0; +} + +.table-header-cell { + position: sticky; + top: 0; + z-index: 2; +} + +.table-row-header { + position: sticky; + left: 0; + z-index: 1; +} + +.table-corner { + position: sticky; + top: 0; + left: 0; + z-index: 3; +} + +.table-cell-input { + width: 100%; + min-width: 0; + min-height: 34px; + padding: 6px 8px; + border: 0; + border-right: 1px solid rgba(165, 180, 252, 0.08); + border-bottom: 1px solid rgba(165, 180, 252, 0.08); + border-radius: 0; + background: rgba(16, 20, 38, 0.34); + color: var(--color-text-primary); + caret-color: transparent; + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; +} + +.table-cell-input:hover { + background: rgba(32, 39, 69, 0.38); +} + +.table-cell-input:focus { + position: relative; + z-index: 4; + border-color: rgba(34, 211, 238, 0.42); + background: + linear-gradient(135deg, rgba(34, 211, 238, 0.08), rgba(139, 92, 246, 0.06)), + rgba(21, 26, 48, 0.58); + box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.58); + outline: 0; +} + +.table-cell-input.is-editing:focus { + border-color: rgba(246, 196, 83, 0.38); + background: rgba(21, 26, 48, 0.72); + caret-color: auto; + box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.55), 0 0 0 2px rgba(139, 92, 246, 0.18); +} + +.table-cell-input.is-formula { + color: var(--color-accent-cyan); +} + +.table-cell-input.has-error { + color: #fecaca; + background: rgba(127, 29, 29, 0.24); +} + +.table-cell-input.is-selected { + background: + linear-gradient(135deg, rgba(34, 211, 238, 0.1), rgba(139, 92, 246, 0.08)), + rgba(21, 26, 48, 0.58); + box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.42); +} + +.table-cell-input.is-selected:focus { + box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.6), 0 0 0 2px rgba(34, 211, 238, 0.2); +} + +.table-cell-input.is-selected:focus:not(.is-editing) { + box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.68); +} + +.table-cell-input.is-referenced { + box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.72); +} + .tool-split-results { grid-template-rows: auto minmax(0, 1fr); gap: var(--space-3); @@ -2968,6 +3163,7 @@ textarea:focus { .tool-split-action-button.danger, .tool-split-entry > button.danger, +.notepad-toolbar-button.danger, .counter-actions button.danger, .link-item button.danger, .task-planner-clear-completed-button.danger, @@ -2979,6 +3175,7 @@ textarea:focus { .tool-split-action-button.danger:hover:not(:disabled), .tool-split-entry > button.danger:hover, +.notepad-toolbar-button.danger:hover:not(:disabled), .counter-actions button.danger:hover, .link-item button.danger:hover, .task-planner-clear-completed-button.danger:hover:not(:disabled), @@ -2995,6 +3192,7 @@ textarea:focus { .tool-split-action-button.danger:hover .ui-icon, .tool-split-entry > button.danger:hover .ui-icon, +.notepad-toolbar-button.danger:hover .ui-icon, .counter-actions button.danger:hover .ui-icon, .link-item button.danger:hover .ui-icon, .task-planner-clear-completed-button.danger:hover .ui-icon,