// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV. import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js"; import { HeaderLabelInput, TableRow } from "./TableGridParts.jsx"; import { DEFAULT_COLUMNS, DEFAULT_ROWS, MAX_COLUMNS, MAX_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 (
{textExchangeOpen && (
context.setModuleData(toolboxId, moduleId, nextData, "table")} />
)}
{data.rows} x {data.columns}
`minmax(${width}px, 1fr)`).join(" ")}`, "--table-min-width": `${rowHeaderWidth + columnWidths.reduce((total, width) => total + width, 0)}px` }} role="grid" aria-label={textContent.gridLabel || "Tableau"} >
); }