From d4fe3c796ab6a95f75dd1958ac4fc372d2828397 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 14 Aug 2026 10:07:26 +0200 Subject: [PATCH] Extract table module helpers --- tests/static-toolboxes.test.mjs | 43 ++- .../toolboxes/modules/TableGridParts.jsx | 93 ++++++ .../toolboxes/modules/TableModule.jsx | 313 ++---------------- .../features/toolboxes/modules/tableUtils.js | 203 ++++++++++++ 4 files changed, 353 insertions(+), 299 deletions(-) create mode 100644 website/src/features/toolboxes/modules/TableGridParts.jsx create mode 100644 website/src/features/toolboxes/modules/tableUtils.js diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 8d1f133..dc411e8 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -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 { 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 { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js"; test("toolbox storage, cards and pages are wired", async () => { 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 calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.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 timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.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, /movingSelectionRef/); assert.match(tableModule, /copiedSelectionRef/); - assert.match(tableModule, /moveSelectionCells/); - assert.match(tableModule, /pasteCellBlock/); - assert.match(tableModule, /parseTsvBlock/); - assert.match(tableModule, /is-move-target/); - assert.match(tableModule, /isMultiCellSelection/); + assert.match(tableUtils, /moveSelectionCells/); + assert.match(tableUtils, /pasteCellBlock/); + assert.match(tableUtils, /parseTsvBlock/); + assert.match(tableGridParts, /is-move-target/); + assert.match(tableUtils, /isMultiCellSelection/); assert.match(tableModule, /handleCellKeyDown/); assert.match(tableModule, /clearSelection/); 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, /ArrowLeft/); assert.match(tableModule, /ArrowRight/); - assert.match(tableModule, /function HeaderLabelInput/); - assert.match(tableModule, /useInlineEdit/); + assert.match(tableGridParts, /export function HeaderLabelInput/); + assert.match(tableGridParts, /useInlineEdit/); assert.match(tableModule, /TextExchangeActions/); - assert.match(tableModule, /event\.target\.select/); + assert.match(tableGridParts, /event\.target\.select/); assert.match(tableFormulaEngine, /export function evaluateTableCell/); assert.doesNotMatch(tableFormulaEngine, /Function\(/); 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(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" }); +}); diff --git a/website/src/features/toolboxes/modules/TableGridParts.jsx b/website/src/features/toolboxes/modules/TableGridParts.jsx new file mode 100644 index 0000000..30d3764 --- /dev/null +++ b/website/src/features/toolboxes/modules/TableGridParts.jsx @@ -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 ( + <> + 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 ( + 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 ( + { + if (value === fallback) window.requestAnimationFrame(() => event.target.select()); + }, + spellCheck: "false" + })} + /> + ); +} diff --git a/website/src/features/toolboxes/modules/TableModule.jsx b/website/src/features/toolboxes/modules/TableModule.jsx index d6f3d37..50a9e64 100644 --- a/website/src/features/toolboxes/modules/TableModule.jsx +++ b/website/src/features/toolboxes/modules/TableModule.jsx @@ -1,210 +1,30 @@ // 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 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) - }; -} +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, editing }) { 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 }) { ); } - -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 ( - <> - 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 ( - 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 ( - { - if (value === fallback) window.requestAnimationFrame(() => event.target.select()); - }, - spellCheck: "false" - })} - /> - ); -} diff --git a/website/src/features/toolboxes/modules/tableUtils.js b/website/src/features/toolboxes/modules/tableUtils.js new file mode 100644 index 0000000..0c3fa87 --- /dev/null +++ b/website/src/features/toolboxes/modules/tableUtils.js @@ -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) + }; +}