// 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 { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { TextExchangeActions } from "./TextExchangeActions.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, editing }) { 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 (