Improve home onboarding and table interactions
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
3cbd579fb7
commit
53ecde8d2b
9 changed files with 488 additions and 5 deletions
|
|
@ -72,6 +72,10 @@ function isCellSelected(selection, rowIndex, columnIndex) {
|
|||
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));
|
||||
|
|
@ -102,13 +106,116 @@ function getFormulaRangeText(selection) {
|
|||
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)
|
||||
};
|
||||
}
|
||||
|
||||
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 [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);
|
||||
|
|
@ -118,6 +225,10 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
|||
|
||||
useEffect(() => {
|
||||
function stopSelectionDrag() {
|
||||
if (movingSelectionRef.current) {
|
||||
finishMovingSelection();
|
||||
return;
|
||||
}
|
||||
if (formulaSelectionRef.current) {
|
||||
const formulaSelection = formulaSelectionRef.current;
|
||||
formulaSelectionRef.current = null;
|
||||
|
|
@ -166,6 +277,48 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
|||
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,
|
||||
|
|
@ -295,7 +448,9 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
|||
}
|
||||
|
||||
async function copyTsv() {
|
||||
const text = getTsv(isMultiCellSelection(selection) ? getSelectionBounds(selection) : null);
|
||||
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é");
|
||||
}
|
||||
|
|
@ -303,11 +458,34 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
|||
async function copySelection(event) {
|
||||
if (!selection || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "c") return;
|
||||
event.preventDefault();
|
||||
const text = getTsv(getSelectionBounds(selection));
|
||||
const bounds = getSelectionBounds(selection);
|
||||
copiedSelectionRef.current = getCellBlock(data.cells, bounds);
|
||||
const text = getTsv(bounds);
|
||||
if (!await context.copyText(text)) return;
|
||||
context.notify?.(textContent.copiedTitle || "Tableau copié");
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -517,6 +695,7 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
|||
textContent={textContent}
|
||||
rowLabel={data.rowLabels?.[rowIndex] || ""}
|
||||
selection={selection}
|
||||
movePreview={movePreview}
|
||||
onRowLabelChange={setRowLabel}
|
||||
onFocus={(cell) => {
|
||||
const parsed = parseCellAddress(cell);
|
||||
|
|
@ -528,8 +707,11 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
|||
onCellChange={setCellValue}
|
||||
onFormulaSelectionStart={startFormulaSelection}
|
||||
onFormulaSelectionExtend={extendFormulaSelection}
|
||||
onStartMoveSelection={startMovingSelection}
|
||||
onMoveSelection={updateMovePreview}
|
||||
onSelectCell={selectCell}
|
||||
onExtendSelection={extendSelection}
|
||||
onPasteSelection={pasteSelection}
|
||||
draggingSelectionRef={draggingSelectionRef}
|
||||
onCellKeyDown={handleCellKeyDown}
|
||||
onStartEditing={startEditing}
|
||||
|
|
@ -542,7 +724,7 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onSelectCell, onExtendSelection, draggingSelectionRef, onCellKeyDown, onStartEditing, registerInput }) {
|
||||
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 (
|
||||
<>
|
||||
<HeaderLabelInput
|
||||
|
|
@ -566,6 +748,7 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
|
|||
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(" ");
|
||||
|
||||
|
|
@ -578,6 +761,8 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
|
|||
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);
|
||||
|
|
@ -586,12 +771,14 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
|
|||
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue