474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
|
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
|
import { HeaderLabelInput, TableRow } from "./TableGridParts.jsx";
|
|
import { TableToolbar } from "./TableToolbar.jsx";
|
|
import {
|
|
DEFAULT_COLUMNS,
|
|
DEFAULT_ROWS,
|
|
clampCellPosition,
|
|
cleanCells,
|
|
cleanLabels,
|
|
getBoundsFromStartAndSize,
|
|
getCellBlock,
|
|
getColumnWidths,
|
|
getFormulaRangeText,
|
|
getRowHeaderWidth,
|
|
getSelectionBounds,
|
|
isCellSelected,
|
|
isMultiCellSelection,
|
|
isSameBounds,
|
|
moveSelectionCells,
|
|
parseTsvBlock,
|
|
pasteCellBlock
|
|
} from "./tableUtils.js";
|
|
|
|
export function TableModule({ toolboxId, moduleId, context, textExchangeOpen }) {
|
|
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 [movePreview, setMovePreview] = useState(null);
|
|
const draggingSelectionRef = useRef(false);
|
|
const movingSelectionRef = useRef(null);
|
|
const copiedSelectionRef = useRef(null);
|
|
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 (movingSelectionRef.current) {
|
|
finishMovingSelection();
|
|
return;
|
|
}
|
|
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 startMovingSelection(rowIndex, columnIndex) {
|
|
const sourceBounds = getSelectionBounds(selection);
|
|
if (!sourceBounds || !isCellSelected(selection, rowIndex, columnIndex)) return false;
|
|
const height = sourceBounds.endRow - sourceBounds.startRow + 1;
|
|
const width = sourceBounds.endColumn - sourceBounds.startColumn + 1;
|
|
const targetBounds = getBoundsFromStartAndSize(sourceBounds.startRow, sourceBounds.startColumn, height, width);
|
|
movingSelectionRef.current = { sourceBounds, targetBounds, height, width };
|
|
setMovePreview(targetBounds);
|
|
return true;
|
|
}
|
|
|
|
function updateMovePreview(rowIndex, columnIndex) {
|
|
const movingSelection = movingSelectionRef.current;
|
|
if (!movingSelection) return false;
|
|
const targetRow = Math.min(data.rows - movingSelection.height, Math.max(0, rowIndex));
|
|
const targetColumn = Math.min(data.columns - movingSelection.width, Math.max(0, columnIndex));
|
|
const targetBounds = getBoundsFromStartAndSize(targetRow, targetColumn, movingSelection.height, movingSelection.width);
|
|
movingSelectionRef.current = { ...movingSelection, targetBounds };
|
|
setMovePreview(targetBounds);
|
|
return true;
|
|
}
|
|
|
|
function finishMovingSelection() {
|
|
const movingSelection = movingSelectionRef.current;
|
|
movingSelectionRef.current = null;
|
|
draggingSelectionRef.current = false;
|
|
setMovePreview(null);
|
|
if (!movingSelection || isSameBounds(movingSelection.sourceBounds, movingSelection.targetBounds)) return;
|
|
const nextCells = moveSelectionCells(data.cells, movingSelection.sourceBounds, movingSelection.targetBounds);
|
|
save({ ...data, cells: nextCells });
|
|
const anchor = {
|
|
rowIndex: movingSelection.targetBounds.startRow,
|
|
columnIndex: movingSelection.targetBounds.startColumn
|
|
};
|
|
const focus = {
|
|
rowIndex: movingSelection.targetBounds.endRow,
|
|
columnIndex: movingSelection.targetBounds.endColumn
|
|
};
|
|
setSelection({ anchor, focus });
|
|
focusCell(anchor.rowIndex, anchor.columnIndex);
|
|
}
|
|
|
|
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 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 bounds = isMultiCellSelection(selection) ? getSelectionBounds(selection) : null;
|
|
copiedSelectionRef.current = getCellBlock(data.cells, bounds || getBoundsFromStartAndSize(0, 0, data.rows, data.columns));
|
|
const text = getTsv(bounds);
|
|
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 bounds = getSelectionBounds(selection);
|
|
copiedSelectionRef.current = getCellBlock(data.cells, bounds);
|
|
const text = getTsv(bounds);
|
|
await context.copyText(text);
|
|
}
|
|
|
|
function pasteBlock(block, rowIndex, columnIndex) {
|
|
const result = pasteCellBlock(data.cells, block, rowIndex, columnIndex, data.rows, data.columns);
|
|
if (!result) return false;
|
|
save({ ...data, cells: result.cells });
|
|
setSelection({
|
|
anchor: { rowIndex: result.bounds.startRow, columnIndex: result.bounds.startColumn },
|
|
focus: { rowIndex: result.bounds.endRow, columnIndex: result.bounds.endColumn }
|
|
});
|
|
focusCell(result.bounds.startRow, result.bounds.startColumn);
|
|
return true;
|
|
}
|
|
|
|
function pasteSelection(event, rowIndex, columnIndex, isEditing) {
|
|
if (isEditing) return;
|
|
const text = event.clipboardData?.getData("text/plain") || "";
|
|
const block = parseTsvBlock(text) || copiedSelectionRef.current;
|
|
if (!block) return;
|
|
event.preventDefault();
|
|
pasteBlock(block, rowIndex, columnIndex);
|
|
}
|
|
|
|
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">
|
|
{textExchangeOpen && (
|
|
<div className="module-text-exchange-panel">
|
|
<TextExchangeActions type="table" data={data} context={context} textContent={textContent} exportLabel="Exporter TSV" onImport={(nextData) => context.setModuleData(toolboxId, moduleId, nextData, "table")} />
|
|
</div>
|
|
)}
|
|
<TableToolbar rows={data.rows} columns={data.columns} textContent={textContent} onResize={resize} onCopy={copyTsv} />
|
|
<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}
|
|
movePreview={movePreview}
|
|
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}
|
|
onStartMoveSelection={startMovingSelection}
|
|
onMoveSelection={updateMovePreview}
|
|
onSelectCell={selectCell}
|
|
onExtendSelection={extendSelection}
|
|
onPasteSelection={pasteSelection}
|
|
draggingSelectionRef={draggingSelectionRef}
|
|
onCellKeyDown={handleCellKeyDown}
|
|
onStartEditing={startEditing}
|
|
registerInput={registerInput}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|