Extract table module helpers
This commit is contained in:
parent
df0033739c
commit
d4fe3c796a
4 changed files with 353 additions and 299 deletions
|
|
@ -5,6 +5,7 @@ import assert from "node:assert/strict";
|
||||||
import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "../website/src/features/toolboxes/modules/equipmentPlannerOptions.js";
|
import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "../website/src/features/toolboxes/modules/equipmentPlannerOptions.js";
|
||||||
import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js";
|
import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js";
|
||||||
import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js";
|
import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js";
|
||||||
|
import { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js";
|
||||||
|
|
||||||
test("toolbox storage, cards and pages are wired", async () => {
|
test("toolbox storage, cards and pages are wired", async () => {
|
||||||
const source = await readFile("website/src/main.jsx", "utf8");
|
const source = await readFile("website/src/main.jsx", "utf8");
|
||||||
|
|
@ -119,6 +120,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8");
|
const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8");
|
||||||
const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.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 tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8");
|
||||||
|
const tableGridParts = await readFile("website/src/features/toolboxes/modules/TableGridParts.jsx", "utf8");
|
||||||
|
const tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8");
|
||||||
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "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 timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
|
||||||
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
|
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
|
||||||
|
|
@ -241,11 +244,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(tableModule, /draggingSelectionRef/);
|
assert.match(tableModule, /draggingSelectionRef/);
|
||||||
assert.match(tableModule, /movingSelectionRef/);
|
assert.match(tableModule, /movingSelectionRef/);
|
||||||
assert.match(tableModule, /copiedSelectionRef/);
|
assert.match(tableModule, /copiedSelectionRef/);
|
||||||
assert.match(tableModule, /moveSelectionCells/);
|
assert.match(tableUtils, /moveSelectionCells/);
|
||||||
assert.match(tableModule, /pasteCellBlock/);
|
assert.match(tableUtils, /pasteCellBlock/);
|
||||||
assert.match(tableModule, /parseTsvBlock/);
|
assert.match(tableUtils, /parseTsvBlock/);
|
||||||
assert.match(tableModule, /is-move-target/);
|
assert.match(tableGridParts, /is-move-target/);
|
||||||
assert.match(tableModule, /isMultiCellSelection/);
|
assert.match(tableUtils, /isMultiCellSelection/);
|
||||||
assert.match(tableModule, /handleCellKeyDown/);
|
assert.match(tableModule, /handleCellKeyDown/);
|
||||||
assert.match(tableModule, /clearSelection/);
|
assert.match(tableModule, /clearSelection/);
|
||||||
assert.match(tableModule, /event\.key === "Delete"/);
|
assert.match(tableModule, /event\.key === "Delete"/);
|
||||||
|
|
@ -253,10 +256,10 @@ test("toolbox module registry and modules expose expected behavior", async () =>
|
||||||
assert.match(tableModule, /ArrowDown/);
|
assert.match(tableModule, /ArrowDown/);
|
||||||
assert.match(tableModule, /ArrowLeft/);
|
assert.match(tableModule, /ArrowLeft/);
|
||||||
assert.match(tableModule, /ArrowRight/);
|
assert.match(tableModule, /ArrowRight/);
|
||||||
assert.match(tableModule, /function HeaderLabelInput/);
|
assert.match(tableGridParts, /export function HeaderLabelInput/);
|
||||||
assert.match(tableModule, /useInlineEdit/);
|
assert.match(tableGridParts, /useInlineEdit/);
|
||||||
assert.match(tableModule, /TextExchangeActions/);
|
assert.match(tableModule, /TextExchangeActions/);
|
||||||
assert.match(tableModule, /event\.target\.select/);
|
assert.match(tableGridParts, /event\.target\.select/);
|
||||||
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
|
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
|
||||||
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
|
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
|
||||||
assert.doesNotMatch(tableFormulaEngine, /\beval\(/);
|
assert.doesNotMatch(tableFormulaEngine, /\beval\(/);
|
||||||
|
|
@ -424,3 +427,27 @@ test("combos helpers keep labels, hold parsing and custom keys stable", () => {
|
||||||
assert.equal(parseHoldSeconds("abc"), 0);
|
assert.equal(parseHoldSeconds("abc"), 0);
|
||||||
assert.equal(normalizeCustomKey(" Ctrl Alt Shift "), "Ctrl Alt Shift");
|
assert.equal(normalizeCustomKey(" Ctrl Alt Shift "), "Ctrl Alt Shift");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("table helpers keep selection, movement and TSV behavior stable", () => {
|
||||||
|
const selection = {
|
||||||
|
anchor: { rowIndex: 2, columnIndex: 3 },
|
||||||
|
focus: { rowIndex: 1, columnIndex: 1 }
|
||||||
|
};
|
||||||
|
assert.deepEqual(getSelectionBounds(selection), {
|
||||||
|
startRow: 1,
|
||||||
|
endRow: 2,
|
||||||
|
startColumn: 1,
|
||||||
|
endColumn: 3
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseTsvBlock("A\tB\nC\tD"), {
|
||||||
|
height: 2,
|
||||||
|
width: 2,
|
||||||
|
values: [["A", "B"], ["C", "D"]]
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = moveSelectionCells({ A1: "a", B1: "b", A2: "c" }, getBoundsFromStartAndSize(0, 0, 1, 2), getBoundsFromStartAndSize(1, 1, 1, 2));
|
||||||
|
assert.deepEqual(moved, { A2: "c", B2: "a", C2: "b" });
|
||||||
|
|
||||||
|
const pasted = pasteCellBlock({}, parseTsvBlock("A\t\nC\tD"), 0, 0, 2, 2);
|
||||||
|
assert.deepEqual(pasted.cells, { A1: "A", A2: "C", B2: "D" });
|
||||||
|
});
|
||||||
|
|
|
||||||
93
website/src/features/toolboxes/modules/TableGridParts.jsx
Normal file
93
website/src/features/toolboxes/modules/TableGridParts.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// Rôle : rend les en-têtes et lignes de cellules du module Table.
|
||||||
|
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
||||||
|
import { cellAddress } from "./tableFormulaEngine.js";
|
||||||
|
import { isCellInBounds, isCellSelected } from "./tableUtils.js";
|
||||||
|
|
||||||
|
export function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, movePreview, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onStartMoveSelection, onMoveSelection, onSelectCell, onExtendSelection, onPasteSelection, 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" : "",
|
||||||
|
isCellInBounds(movePreview, rowIndex, columnIndex) ? "is-move-target" : "",
|
||||||
|
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 if (!event.shiftKey && onStartMoveSelection(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 (onMoveSelection(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)}
|
||||||
|
onPaste={(event) => onPasteSelection(event, rowIndex, columnIndex, isEditing)}
|
||||||
|
aria-label={`${textContent.cellLabel || "Cellule"} ${address}`}
|
||||||
|
title={evaluated?.error && evaluated.error !== "empty" ? textContent.errorTitle || "Formule invalide" : address}
|
||||||
|
inputMode="text"
|
||||||
|
readOnly={!isEditing}
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderLabelInput({ className, value, fallback, onSave, ...props }) {
|
||||||
|
const inlineEdit = useInlineEdit({
|
||||||
|
value,
|
||||||
|
onCommit: onSave,
|
||||||
|
transform: (draft) => String(draft || ""),
|
||||||
|
blurOnEscape: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...inlineEdit.getInputProps({
|
||||||
|
...props,
|
||||||
|
className,
|
||||||
|
onFocus: (event) => {
|
||||||
|
if (value === fallback) window.requestAnimationFrame(() => event.target.select());
|
||||||
|
},
|
||||||
|
spellCheck: "false"
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,210 +1,30 @@
|
||||||
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
|
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
||||||
|
import { HeaderLabelInput, TableRow } from "./TableGridParts.jsx";
|
||||||
const DEFAULT_ROWS = 10;
|
import {
|
||||||
const DEFAULT_COLUMNS = 6;
|
DEFAULT_COLUMNS,
|
||||||
const MAX_ROWS = 50;
|
DEFAULT_ROWS,
|
||||||
const MAX_COLUMNS = 20;
|
MAX_COLUMNS,
|
||||||
const MIN_COLUMN_WIDTH = 96;
|
MAX_ROWS,
|
||||||
const MAX_COLUMN_WIDTH = 220;
|
clampCellPosition,
|
||||||
const MIN_ROW_HEADER_WIDTH = 42;
|
cleanCells,
|
||||||
const MAX_ROW_HEADER_WIDTH = 180;
|
cleanLabels,
|
||||||
|
getBoundsFromStartAndSize,
|
||||||
function cleanCells(cells, rows, columns) {
|
getCellBlock,
|
||||||
const nextCells = {};
|
getColumnWidths,
|
||||||
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
getFormulaRangeText,
|
||||||
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
|
getRowHeaderWidth,
|
||||||
const address = cellAddress(rowIndex, columnIndex);
|
getSelectionBounds,
|
||||||
const value = String(cells?.[address] || "");
|
isCellSelected,
|
||||||
if (value.trim()) nextCells[address] = value;
|
isMultiCellSelection,
|
||||||
}
|
isSameBounds,
|
||||||
}
|
moveSelectionCells,
|
||||||
return nextCells;
|
parseTsvBlock,
|
||||||
}
|
pasteCellBlock
|
||||||
|
} from "./tableUtils.js";
|
||||||
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 isCellInBounds(bounds, rowIndex, columnIndex) {
|
|
||||||
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("+")})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCellBlock(cells, bounds) {
|
|
||||||
if (!bounds) return null;
|
|
||||||
const values = [];
|
|
||||||
for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) {
|
|
||||||
const row = [];
|
|
||||||
for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) {
|
|
||||||
row.push(cells[cellAddress(rowIndex, columnIndex)] || "");
|
|
||||||
}
|
|
||||||
values.push(row);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
height: bounds.endRow - bounds.startRow + 1,
|
|
||||||
width: bounds.endColumn - bounds.startColumn + 1,
|
|
||||||
values
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTsvBlock(text) {
|
|
||||||
const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
||||||
const lines = normalizedText.endsWith("\n") ? normalizedText.slice(0, -1).split("\n") : normalizedText.split("\n");
|
|
||||||
if (!lines.length || (lines.length === 1 && !lines[0])) return null;
|
|
||||||
const values = lines.map((line) => line.split("\t"));
|
|
||||||
return {
|
|
||||||
height: values.length,
|
|
||||||
width: Math.max(...values.map((row) => row.length)),
|
|
||||||
values
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBoundsFromStartAndSize(rowIndex, columnIndex, height, width) {
|
|
||||||
return {
|
|
||||||
startRow: rowIndex,
|
|
||||||
endRow: rowIndex + height - 1,
|
|
||||||
startColumn: columnIndex,
|
|
||||||
endColumn: columnIndex + width - 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameBounds(firstBounds, secondBounds) {
|
|
||||||
return Boolean(firstBounds && secondBounds
|
|
||||||
&& firstBounds.startRow === secondBounds.startRow
|
|
||||||
&& firstBounds.endRow === secondBounds.endRow
|
|
||||||
&& firstBounds.startColumn === secondBounds.startColumn
|
|
||||||
&& firstBounds.endColumn === secondBounds.endColumn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveSelectionCells(cells, sourceBounds, targetBounds) {
|
|
||||||
const nextCells = { ...cells };
|
|
||||||
const snapshot = [];
|
|
||||||
|
|
||||||
for (let rowIndex = sourceBounds.startRow; rowIndex <= sourceBounds.endRow; rowIndex += 1) {
|
|
||||||
for (let columnIndex = sourceBounds.startColumn; columnIndex <= sourceBounds.endColumn; columnIndex += 1) {
|
|
||||||
const value = cells[cellAddress(rowIndex, columnIndex)] || "";
|
|
||||||
snapshot.push({
|
|
||||||
rowOffset: rowIndex - sourceBounds.startRow,
|
|
||||||
columnOffset: columnIndex - sourceBounds.startColumn,
|
|
||||||
value
|
|
||||||
});
|
|
||||||
delete nextCells[cellAddress(rowIndex, columnIndex)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let rowIndex = targetBounds.startRow; rowIndex <= targetBounds.endRow; rowIndex += 1) {
|
|
||||||
for (let columnIndex = targetBounds.startColumn; columnIndex <= targetBounds.endColumn; columnIndex += 1) {
|
|
||||||
delete nextCells[cellAddress(rowIndex, columnIndex)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot.forEach(({ rowOffset, columnOffset, value }) => {
|
|
||||||
if (!String(value || "").trim()) return;
|
|
||||||
nextCells[cellAddress(targetBounds.startRow + rowOffset, targetBounds.startColumn + columnOffset)] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return nextCells;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pasteCellBlock(cells, block, targetRow, targetColumn, rows, columns) {
|
|
||||||
const clippedHeight = Math.min(block?.height || 0, rows - targetRow);
|
|
||||||
const clippedWidth = Math.min(block?.width || 0, columns - targetColumn);
|
|
||||||
if (clippedHeight <= 0 || clippedWidth <= 0) return null;
|
|
||||||
|
|
||||||
const nextCells = { ...cells };
|
|
||||||
for (let rowOffset = 0; rowOffset < clippedHeight; rowOffset += 1) {
|
|
||||||
for (let columnOffset = 0; columnOffset < clippedWidth; columnOffset += 1) {
|
|
||||||
const address = cellAddress(targetRow + rowOffset, targetColumn + columnOffset);
|
|
||||||
const value = String(block.values[rowOffset]?.[columnOffset] || "");
|
|
||||||
if (value.trim()) {
|
|
||||||
nextCells[address] = value;
|
|
||||||
} else {
|
|
||||||
delete nextCells[address];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cells: nextCells,
|
|
||||||
bounds: getBoundsFromStartAndSize(targetRow, targetColumn, clippedHeight, clippedWidth)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableModule({ toolboxId, moduleId, context, editing }) {
|
export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
||||||
|
|
@ -716,92 +536,3 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, movePreview, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onStartMoveSelection, onMoveSelection, onSelectCell, onExtendSelection, onPasteSelection, 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" : "",
|
|
||||||
isCellInBounds(movePreview, rowIndex, columnIndex) ? "is-move-target" : "",
|
|
||||||
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 if (!event.shiftKey && onStartMoveSelection(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 (onMoveSelection(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)}
|
|
||||||
onPaste={(event) => onPasteSelection(event, rowIndex, columnIndex, 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 inlineEdit = useInlineEdit({
|
|
||||||
value,
|
|
||||||
onCommit: onSave,
|
|
||||||
transform: (draft) => String(draft || ""),
|
|
||||||
blurOnEscape: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
{...inlineEdit.getInputProps({
|
|
||||||
...props,
|
|
||||||
className,
|
|
||||||
onFocus: (event) => {
|
|
||||||
if (value === fallback) window.requestAnimationFrame(() => event.target.select());
|
|
||||||
},
|
|
||||||
spellCheck: "false"
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
203
website/src/features/toolboxes/modules/tableUtils.js
Normal file
203
website/src/features/toolboxes/modules/tableUtils.js
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
// Rôle : regroupe les limites, dimensions et helpers purs de sélection/copie du tableau.
|
||||||
|
import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
|
||||||
|
|
||||||
|
export const DEFAULT_ROWS = 10;
|
||||||
|
export const DEFAULT_COLUMNS = 6;
|
||||||
|
export const MAX_ROWS = 50;
|
||||||
|
export const MAX_COLUMNS = 20;
|
||||||
|
export const MIN_COLUMN_WIDTH = 96;
|
||||||
|
export const MAX_COLUMN_WIDTH = 220;
|
||||||
|
export const MIN_ROW_HEADER_WIDTH = 42;
|
||||||
|
export const MAX_ROW_HEADER_WIDTH = 180;
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateTextWidth(value, minWidth, maxWidth) {
|
||||||
|
const length = String(value || "").length;
|
||||||
|
return Math.min(maxWidth, Math.max(minWidth, 24 + length * 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellSelected(selection, rowIndex, columnIndex) {
|
||||||
|
const bounds = getSelectionBounds(selection);
|
||||||
|
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellInBounds(bounds, rowIndex, columnIndex) {
|
||||||
|
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMultiCellSelection(selection) {
|
||||||
|
const bounds = getSelectionBounds(selection);
|
||||||
|
return Boolean(bounds && (bounds.startRow !== bounds.endRow || bounds.startColumn !== bounds.endColumn));
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormulaRangeText(selection) {
|
||||||
|
const addresses = getRangeAddresses(selection);
|
||||||
|
if (!addresses.length) return "";
|
||||||
|
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCellBlock(cells, bounds) {
|
||||||
|
if (!bounds) return null;
|
||||||
|
const values = [];
|
||||||
|
for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) {
|
||||||
|
const row = [];
|
||||||
|
for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) {
|
||||||
|
row.push(cells[cellAddress(rowIndex, columnIndex)] || "");
|
||||||
|
}
|
||||||
|
values.push(row);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
height: bounds.endRow - bounds.startRow + 1,
|
||||||
|
width: bounds.endColumn - bounds.startColumn + 1,
|
||||||
|
values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTsvBlock(text) {
|
||||||
|
const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
const lines = normalizedText.endsWith("\n") ? normalizedText.slice(0, -1).split("\n") : normalizedText.split("\n");
|
||||||
|
if (!lines.length || (lines.length === 1 && !lines[0])) return null;
|
||||||
|
const values = lines.map((line) => line.split("\t"));
|
||||||
|
return {
|
||||||
|
height: values.length,
|
||||||
|
width: Math.max(...values.map((row) => row.length)),
|
||||||
|
values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBoundsFromStartAndSize(rowIndex, columnIndex, height, width) {
|
||||||
|
return {
|
||||||
|
startRow: rowIndex,
|
||||||
|
endRow: rowIndex + height - 1,
|
||||||
|
startColumn: columnIndex,
|
||||||
|
endColumn: columnIndex + width - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameBounds(firstBounds, secondBounds) {
|
||||||
|
return Boolean(firstBounds && secondBounds
|
||||||
|
&& firstBounds.startRow === secondBounds.startRow
|
||||||
|
&& firstBounds.endRow === secondBounds.endRow
|
||||||
|
&& firstBounds.startColumn === secondBounds.startColumn
|
||||||
|
&& firstBounds.endColumn === secondBounds.endColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveSelectionCells(cells, sourceBounds, targetBounds) {
|
||||||
|
const nextCells = { ...cells };
|
||||||
|
const snapshot = [];
|
||||||
|
|
||||||
|
for (let rowIndex = sourceBounds.startRow; rowIndex <= sourceBounds.endRow; rowIndex += 1) {
|
||||||
|
for (let columnIndex = sourceBounds.startColumn; columnIndex <= sourceBounds.endColumn; columnIndex += 1) {
|
||||||
|
const value = cells[cellAddress(rowIndex, columnIndex)] || "";
|
||||||
|
snapshot.push({
|
||||||
|
rowOffset: rowIndex - sourceBounds.startRow,
|
||||||
|
columnOffset: columnIndex - sourceBounds.startColumn,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
delete nextCells[cellAddress(rowIndex, columnIndex)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let rowIndex = targetBounds.startRow; rowIndex <= targetBounds.endRow; rowIndex += 1) {
|
||||||
|
for (let columnIndex = targetBounds.startColumn; columnIndex <= targetBounds.endColumn; columnIndex += 1) {
|
||||||
|
delete nextCells[cellAddress(rowIndex, columnIndex)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.forEach(({ rowOffset, columnOffset, value }) => {
|
||||||
|
if (!String(value || "").trim()) return;
|
||||||
|
nextCells[cellAddress(targetBounds.startRow + rowOffset, targetBounds.startColumn + columnOffset)] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pasteCellBlock(cells, block, targetRow, targetColumn, rows, columns) {
|
||||||
|
const clippedHeight = Math.min(block?.height || 0, rows - targetRow);
|
||||||
|
const clippedWidth = Math.min(block?.width || 0, columns - targetColumn);
|
||||||
|
if (clippedHeight <= 0 || clippedWidth <= 0) return null;
|
||||||
|
|
||||||
|
const nextCells = { ...cells };
|
||||||
|
for (let rowOffset = 0; rowOffset < clippedHeight; rowOffset += 1) {
|
||||||
|
for (let columnOffset = 0; columnOffset < clippedWidth; columnOffset += 1) {
|
||||||
|
const address = cellAddress(targetRow + rowOffset, targetColumn + columnOffset);
|
||||||
|
const value = String(block.values[rowOffset]?.[columnOffset] || "");
|
||||||
|
if (value.trim()) {
|
||||||
|
nextCells[address] = value;
|
||||||
|
} else {
|
||||||
|
delete nextCells[address];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cells: nextCells,
|
||||||
|
bounds: getBoundsFromStartAndSize(targetRow, targetColumn, clippedHeight, clippedWidth)
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue