add new table tool
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s

This commit is contained in:
Shinuwa 2026-07-30 11:32:33 +02:00
parent 896ae23ff2
commit 025cd4bf22
19 changed files with 1336 additions and 1 deletions

View 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"
/>
);
}