This commit is contained in:
parent
896ae23ff2
commit
025cd4bf22
19 changed files with 1336 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
2
website/public/static/icons/add-column.svg
Normal file
2
website/public/static/icons/add-column.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"><path stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h3M3 21h3m0 0h4a2 2 0 0 0 2-2V9M6 21V9m0-6h4a2 2 0 0 1 2 2v4M6 3v6M3 9h3m0 0h6m-9 6h9m3-3h3m0 0h3m-3 0v3m0-3V9"/></svg>
|
||||
|
After Width: | Height: | Size: 433 B |
2
website/public/static/icons/add-row.svg
Normal file
2
website/public/static/icons/add-row.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"><path stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3v3m18-3v3m0 0v4a2 2 0 0 1-2 2H9m12-6H9M3 6v4a2 2 0 0 0 2 2h4M3 6h6m0-3v3m0 0v6m6-9v9m-3 3v3m0 0v3m0-3h3m-3 0H9"/></svg>
|
||||
|
After Width: | Height: | Size: 433 B |
2
website/public/static/icons/remove-column.svg
Normal file
2
website/public/static/icons/remove-column.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"><path stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h3M3 21h3m0 0h4a2 2 0 0 0 2-2V9M6 21V9m0-6h4a2 2 0 0 1 2 2v4M6 3v6M3 9h3m0 0h6m-9 6h9m3-6 3 3m0 0 3 3m-3-3 3-3m-3 3-3 3"/></svg>
|
||||
|
After Width: | Height: | Size: 442 B |
2
website/public/static/icons/remove-row.svg
Normal file
2
website/public/static/icons/remove-row.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"><path stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3v3m18-3v3m0 0v4a2 2 0 0 1-2 2H9m12-6H9M3 6v4a2 2 0 0 0 2 2h4M3 6h6m0-3v3m0 0v6m6-9v9m-6 3 3 3m0 0 3 3m-3-3 3-3m-3 3-3 3"/></svg>
|
||||
|
After Width: | Height: | Size: 442 B |
4
website/public/static/icons/table.svg
Normal file
4
website/public/static/icons/table.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 9.5H21M3 14.5H21M8 4.5V19.5M6.2 19.5H17.8C18.9201 19.5 19.4802 19.5 19.908 19.282C20.2843 19.0903 20.5903 18.7843 20.782 18.408C21 17.9802 21 17.4201 21 16.3V7.7C21 6.5799 21 6.01984 20.782 5.59202C20.5903 5.21569 20.2843 4.90973 19.908 4.71799C19.4802 4.5 18.9201 4.5 17.8 4.5H6.2C5.0799 4.5 4.51984 4.5 4.09202 4.71799C3.71569 4.90973 3.40973 5.21569 3.21799 5.59202C3 6.01984 3 6.57989 3 7.7V16.3C3 17.4201 3 17.9802 3.21799 18.408C3.40973 18.7843 3.71569 19.0903 4.09202 19.282C4.51984 19.5 5.07989 19.5 6.2 19.5Z" stroke="#000000" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 797 B |
|
|
@ -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,
|
||||
|
|
|
|||
642
website/src/features/toolboxes/modules/TableModule.jsx
Normal file
642
website/src/features/toolboxes/modules/TableModule.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="table-module">
|
||||
<div className="notepad-toolbar table-toolbar" aria-label={textContent.toolbarLabel || "Actions du tableau"}>
|
||||
<div className="notepad-format-controls table-controls">
|
||||
<div className="notepad-toolbar-group" role="group" aria-label={textContent.rowActionsLabel || "Lignes"}>
|
||||
<button
|
||||
className="notepad-toolbar-button"
|
||||
type="button"
|
||||
onClick={() => resize(Math.min(MAX_ROWS, data.rows + 1), data.columns)}
|
||||
disabled={data.rows >= MAX_ROWS}
|
||||
aria-label={textContent.addRowTitle || "Ajouter une ligne"}
|
||||
title={textContent.addRowTitle || "Ajouter une ligne"}
|
||||
>
|
||||
<Icon name="add-row" />
|
||||
</button>
|
||||
<button
|
||||
className="notepad-toolbar-button danger"
|
||||
type="button"
|
||||
onClick={() => resize(Math.max(1, data.rows - 1), data.columns)}
|
||||
disabled={data.rows <= 1}
|
||||
aria-label={textContent.removeRowTitle || "Supprimer la dernière ligne"}
|
||||
title={textContent.removeRowTitle || "Supprimer la dernière ligne"}
|
||||
>
|
||||
<Icon name="remove-row" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="notepad-toolbar-group" role="group" aria-label={textContent.columnActionsLabel || "Colonnes"}>
|
||||
<button
|
||||
className="notepad-toolbar-button"
|
||||
type="button"
|
||||
onClick={() => resize(data.rows, Math.min(MAX_COLUMNS, data.columns + 1))}
|
||||
disabled={data.columns >= MAX_COLUMNS}
|
||||
aria-label={textContent.addColumnTitle || "Ajouter une colonne"}
|
||||
title={textContent.addColumnTitle || "Ajouter une colonne"}
|
||||
>
|
||||
<Icon name="add-column" />
|
||||
</button>
|
||||
<button
|
||||
className="notepad-toolbar-button danger"
|
||||
type="button"
|
||||
onClick={() => resize(data.rows, Math.max(1, data.columns - 1))}
|
||||
disabled={data.columns <= 1}
|
||||
aria-label={textContent.removeColumnTitle || "Supprimer la dernière colonne"}
|
||||
title={textContent.removeColumnTitle || "Supprimer la dernière colonne"}
|
||||
>
|
||||
<Icon name="remove-column" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="notepad-toolbar-group table-size-group" aria-label={textContent.sizeLabel || "Taille du tableau"}>
|
||||
<span className="table-size-indicator">{data.rows} x {data.columns}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="notepad-toolbar-group table-copy-group">
|
||||
<button
|
||||
className="notepad-toolbar-button table-copy-button"
|
||||
type="button"
|
||||
onClick={copyTsv}
|
||||
aria-label={textContent.copyTitle || "Copier en TSV"}
|
||||
title={textContent.copyTitle || "Copier en TSV"}
|
||||
>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-grid-wrap legacy-scrollbar">
|
||||
<div
|
||||
className="table-grid"
|
||||
style={{
|
||||
"--table-columns": `${rowHeaderWidth}px ${columnWidths.map((width) => `minmax(${width}px, 1fr)`).join(" ")}`,
|
||||
"--table-min-width": `${rowHeaderWidth + columnWidths.reduce((total, width) => total + width, 0)}px`
|
||||
}}
|
||||
role="grid"
|
||||
aria-label={textContent.gridLabel || "Tableau"}
|
||||
>
|
||||
<span className="table-corner" aria-hidden="true" />
|
||||
{Array.from({ length: data.columns }, (_, columnIndex) => (
|
||||
<HeaderLabelInput
|
||||
className="table-header-cell table-header-input"
|
||||
key={columnIndex}
|
||||
value={data.columnLabels?.[columnIndex] || columnIndexToName(columnIndex)}
|
||||
fallback={columnIndexToName(columnIndex)}
|
||||
onSave={(value) => setColumnLabel(columnIndex, value)}
|
||||
aria-label={`${textContent.columnLabel || "Intitulé de colonne"} ${columnIndexToName(columnIndex)}`}
|
||||
title={`${columnIndexToName(columnIndex)} - ${textContent.columnFormulaTitle || "Référence conservée dans les formules"}`}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: data.rows }, (_, rowIndex) => (
|
||||
<TableRow
|
||||
key={rowIndex}
|
||||
rowIndex={rowIndex}
|
||||
columns={data.columns}
|
||||
cells={data.cells}
|
||||
evaluatedCells={evaluatedCells}
|
||||
editingCell={editingCell}
|
||||
highlightedCell={highlightedCell}
|
||||
textContent={textContent}
|
||||
rowLabel={data.rowLabels?.[rowIndex] || ""}
|
||||
selection={selection}
|
||||
onRowLabelChange={setRowLabel}
|
||||
onFocus={(cell) => {
|
||||
const parsed = parseCellAddress(cell);
|
||||
if (parsed && !selection) selectCell(parsed.rowIndex, parsed.columnIndex);
|
||||
}}
|
||||
onBlur={(cell) => {
|
||||
if (editingCell === cell) setEditingCell("");
|
||||
}}
|
||||
onCellChange={setCellValue}
|
||||
onFormulaSelectionStart={startFormulaSelection}
|
||||
onFormulaSelectionExtend={extendFormulaSelection}
|
||||
onSelectCell={selectCell}
|
||||
onExtendSelection={extendSelection}
|
||||
draggingSelectionRef={draggingSelectionRef}
|
||||
onCellKeyDown={handleCellKeyDown}
|
||||
onStartEditing={startEditing}
|
||||
registerInput={registerInput}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onSelectCell, onExtendSelection, draggingSelectionRef, onCellKeyDown, onStartEditing, registerInput }) {
|
||||
return (
|
||||
<>
|
||||
<HeaderLabelInput
|
||||
className="table-row-header table-header-input"
|
||||
value={rowLabel || String(rowIndex + 1)}
|
||||
fallback={String(rowIndex + 1)}
|
||||
onSave={(value) => 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 (
|
||||
<input
|
||||
key={address}
|
||||
ref={(element) => 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 (
|
||||
<input
|
||||
{...props}
|
||||
className={className}
|
||||
value={focused ? draft : value}
|
||||
onFocus={(event) => {
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
201
website/src/features/toolboxes/modules/tableFormulaEngine.js
Normal file
201
website/src/features/toolboxes/modules/tableFormulaEngine.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue