From 138a483c65fa8d74f5beb95742355b38ca22b079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Sat, 7 Jun 2025 10:57:12 +0200 Subject: [PATCH] UPDATE: Double click in the outline handle fills column This also removes React from the equation. So all event handling is done outside of the React loop. This simplifies some things and helps us in a possible move away from React. This is closer to how we deal with the column and row handle resizers. I think it works quite well and it is more future proof. But TBH I just want to try it out and see what is the DX after this. Fixes #359 --- .../src/components/Worksheet/Worksheet.tsx | 274 ++++-------------- .../src/components/Worksheet/usePointer.ts | 37 +-- .../WorksheetCanvas/outlineHandle.ts | 211 ++++++++++++++ .../WorksheetCanvas/worksheetCanvas.ts | 67 +---- 4 files changed, 282 insertions(+), 307 deletions(-) create mode 100644 webapp/IronCalc/src/components/WorksheetCanvas/outlineHandle.ts diff --git a/webapp/IronCalc/src/components/Worksheet/Worksheet.tsx b/webapp/IronCalc/src/components/Worksheet/Worksheet.tsx index 55b8574..a97b583 100644 --- a/webapp/IronCalc/src/components/Worksheet/Worksheet.tsx +++ b/webapp/IronCalc/src/components/Worksheet/Worksheet.tsx @@ -24,7 +24,7 @@ import { TOOLBAR_HEIGHT, } from "../constants"; import type { Cell } from "../types"; -import { AreaType, type WorkbookState } from "../workbookState"; +import type { WorkbookState } from "../workbookState"; import CellContextMenu from "./CellContextMenu"; import usePointer from "./usePointer"; @@ -59,7 +59,6 @@ const Worksheet = forwardRef( const spacerElement = useRef(null); const cellOutline = useRef(null); const areaOutline = useRef(null); - const cellOutlineHandle = useRef(null); const extendToOutline = useRef(null); const columnResizeGuide = useRef(null); const rowResizeGuide = useRef(null); @@ -85,7 +84,6 @@ const Worksheet = forwardRef( const worksheetRef = worksheetElement.current; const outline = cellOutline.current; - const handle = cellOutlineHandle.current; const area = areaOutline.current; const extendTo = extendToOutline.current; const editor = editorElement.current; @@ -97,7 +95,6 @@ const Worksheet = forwardRef( !columnHeadersRef || !worksheetRef || !outline || - !handle || !area || !extendTo || !scrollElement.current || @@ -118,7 +115,6 @@ const Worksheet = forwardRef( rowGuide: rowGuideRef, columnHeaders: columnHeadersRef, cellOutline: outline, - cellOutlineHandle: handle, areaOutline: area, extendToOutline: extendTo, editor: editor, @@ -191,203 +187,74 @@ const Worksheet = forwardRef( worksheetCanvas.current = canvas; }); - const { onPointerMove, onPointerDown, onPointerHandleDown, onPointerUp } = - usePointer({ - model, - workbookState, - refresh, - onColumnSelected: (column: number, shift: boolean) => { - let firstColumn = column; - let lastColumn = column; - if (shift) { - const { range } = model.getSelectedView(); - firstColumn = Math.min(range[1], column, range[3]); - lastColumn = Math.max(range[3], column, range[1]); - } - model.setSelectedCell(1, firstColumn); - model.setSelectedRange(1, firstColumn, LAST_ROW, lastColumn); - refresh(); - }, - onRowSelected: (row: number, shift: boolean) => { - let firstRow = row; - let lastRow = row; - if (shift) { - const { range } = model.getSelectedView(); - firstRow = Math.min(range[0], row, range[2]); - lastRow = Math.max(range[2], row, range[0]); - } - model.setSelectedCell(firstRow, 1); - model.setSelectedRange(firstRow, 1, lastRow, LAST_COLUMN); - refresh(); - }, - onAllSheetSelected: () => { - model.setSelectedCell(1, 1); - model.setSelectedRange(1, 1, LAST_ROW, LAST_COLUMN); - }, - onCellSelected: (cell: Cell, event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - model.setSelectedCell(cell.row, cell.column); - refresh(); - }, - onAreaSelecting: (cell: Cell) => { + const { onPointerMove, onPointerDown, onPointerUp } = usePointer({ + model, + workbookState, + refresh, + onColumnSelected: (column: number, shift: boolean) => { + let firstColumn = column; + let lastColumn = column; + if (shift) { + const { range } = model.getSelectedView(); + firstColumn = Math.min(range[1], column, range[3]); + lastColumn = Math.max(range[3], column, range[1]); + } + model.setSelectedCell(1, firstColumn); + model.setSelectedRange(1, firstColumn, LAST_ROW, lastColumn); + refresh(); + }, + onRowSelected: (row: number, shift: boolean) => { + let firstRow = row; + let lastRow = row; + if (shift) { + const { range } = model.getSelectedView(); + firstRow = Math.min(range[0], row, range[2]); + lastRow = Math.max(range[2], row, range[0]); + } + model.setSelectedCell(firstRow, 1); + model.setSelectedRange(firstRow, 1, lastRow, LAST_COLUMN); + refresh(); + }, + onAllSheetSelected: () => { + model.setSelectedCell(1, 1); + model.setSelectedRange(1, 1, LAST_ROW, LAST_COLUMN); + }, + onCellSelected: (cell: Cell, event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + model.setSelectedCell(cell.row, cell.column); + refresh(); + }, + onAreaSelecting: (cell: Cell) => { + const canvas = worksheetCanvas.current; + if (!canvas) { + return; + } + const { row, column } = cell; + model.onAreaSelecting(row, column); + canvas.renderSheet(); + refresh(); + }, + onAreaSelected: () => { + const styles = workbookState.getCopyStyles(); + if (styles?.length) { + model.onPasteStyles(styles); const canvas = worksheetCanvas.current; if (!canvas) { return; } - const { row, column } = cell; - model.onAreaSelecting(row, column); canvas.renderSheet(); - refresh(); - }, - onAreaSelected: () => { - const styles = workbookState.getCopyStyles(); - if (styles?.length) { - model.onPasteStyles(styles); - const canvas = worksheetCanvas.current; - if (!canvas) { - return; - } - canvas.renderSheet(); - } - workbookState.setCopyStyles(null); - if (worksheetElement.current) { - worksheetElement.current.style.cursor = "auto"; - } - refresh(); - }, - onExtendToCell: (cell) => { - const canvas = worksheetCanvas.current; - if (!canvas) { - return; - } - const { row, column } = cell; - const { - range: [rowStart, columnStart, rowEnd, columnEnd], - } = model.getSelectedView(); - // We are either extending by rows or by columns - // And we could be doing it in the positive direction (downwards or right) - // or the negative direction (upwards or left) - - if ( - row > rowEnd && - ((column <= columnEnd && column >= columnStart) || - (column < columnStart && columnStart - column < row - rowEnd) || - (column > columnEnd && column - columnEnd < row - rowEnd)) - ) { - // rows downwards - const area = { - type: AreaType.rowsDown, - rowStart: rowEnd + 1, - rowEnd: row, - columnStart, - columnEnd, - }; - workbookState.setExtendToArea(area); - canvas.renderSheet(); - } else if ( - row < rowStart && - ((column <= columnEnd && column >= columnStart) || - (column < columnStart && columnStart - column < rowStart - row) || - (column > columnEnd && column - columnEnd < rowStart - row)) - ) { - // rows upwards - const area = { - type: AreaType.rowsUp, - rowStart: row, - rowEnd: rowStart, - columnStart, - columnEnd, - }; - workbookState.setExtendToArea(area); - canvas.renderSheet(); - } else if ( - column > columnEnd && - ((row <= rowEnd && row >= rowStart) || - (row < rowStart && rowStart - row < column - columnEnd) || - (row > rowEnd && row - rowEnd < column - columnEnd)) - ) { - // columns right - const area = { - type: AreaType.columnsRight, - rowStart, - rowEnd, - columnStart: columnEnd + 1, - columnEnd: column, - }; - workbookState.setExtendToArea(area); - canvas.renderSheet(); - } else if ( - column < columnStart && - ((row <= rowEnd && row >= rowStart) || - (row < rowStart && rowStart - row < columnStart - column) || - (row > rowEnd && row - rowEnd < columnStart - column)) - ) { - // columns left - const area = { - type: AreaType.columnsLeft, - rowStart, - rowEnd, - columnStart: column, - columnEnd: columnStart, - }; - workbookState.setExtendToArea(area); - canvas.renderSheet(); - } - }, - onExtendToEnd: () => { - const canvas = worksheetCanvas.current; - if (!canvas) { - return; - } - const { sheet, range } = model.getSelectedView(); - const extendedArea = workbookState.getExtendToArea(); - if (!extendedArea) { - return; - } - const rowStart = Math.min(range[0], range[2]); - const height = Math.abs(range[2] - range[0]) + 1; - const width = Math.abs(range[3] - range[1]) + 1; - const columnStart = Math.min(range[1], range[3]); - - const area = { - sheet, - row: rowStart, - column: columnStart, - width, - height, - }; - - switch (extendedArea.type) { - case AreaType.rowsDown: - model.autoFillRows(area, extendedArea.rowEnd); - break; - case AreaType.rowsUp: { - model.autoFillRows(area, extendedArea.rowStart); - break; - } - case AreaType.columnsRight: { - model.autoFillColumns(area, extendedArea.columnEnd); - break; - } - case AreaType.columnsLeft: { - model.autoFillColumns(area, extendedArea.columnStart); - break; - } - } - model.setSelectedRange( - Math.min(rowStart, extendedArea.rowStart), - Math.min(columnStart, extendedArea.columnStart), - Math.max(rowStart + height - 1, extendedArea.rowEnd), - Math.max(columnStart + width - 1, extendedArea.columnEnd), - ); - workbookState.clearExtendToArea(); - canvas.renderSheet(); - }, - canvasElement, - worksheetElement, - worksheetCanvas, - }); + } + workbookState.setCopyStyles(null); + if (worksheetElement.current) { + worksheetElement.current.style.cursor = "auto"; + } + refresh(); + }, + canvasElement, + worksheetElement, + worksheetCanvas, + }); const onScroll = (): void => { if (!scrollElement.current || !worksheetCanvas.current) { @@ -463,10 +330,6 @@ const Worksheet = forwardRef( - @@ -640,15 +503,6 @@ const CellOutline = styled("div")` display: flex; `; -const CellOutlineHandle = styled("div")` - position: absolute; - width: 5px; - height: 5px; - background: ${outlineColor}; - cursor: crosshair; - border-radius: 1px; -`; - const ExtendToOutline = styled("div")` position: absolute; border: 1px dashed ${outlineColor}; diff --git a/webapp/IronCalc/src/components/Worksheet/usePointer.ts b/webapp/IronCalc/src/components/Worksheet/usePointer.ts index 8b37722..3fa1b39 100644 --- a/webapp/IronCalc/src/components/Worksheet/usePointer.ts +++ b/webapp/IronCalc/src/components/Worksheet/usePointer.ts @@ -20,8 +20,6 @@ interface PointerSettings { onAllSheetSelected: () => void; onAreaSelecting: (cell: Cell) => void; onAreaSelected: () => void; - onExtendToCell: (cell: Cell) => void; - onExtendToEnd: () => void; model: Model; workbookState: WorkbookState; refresh: () => void; @@ -31,12 +29,10 @@ interface PointerEvents { onPointerDown: (event: PointerEvent) => void; onPointerMove: (event: PointerEvent) => void; onPointerUp: (event: PointerEvent) => void; - onPointerHandleDown: (event: PointerEvent) => void; } const usePointer = (options: PointerSettings): PointerEvents => { const isSelecting = useRef(false); - const isExtending = useRef(false); const isInsertingRef = useRef(false); const onPointerMove = useCallback( @@ -47,9 +43,7 @@ const usePointer = (options: PointerSettings): PointerEvents => { return; } - if ( - !(isSelecting.current || isExtending.current || isInsertingRef.current) - ) { + if (!(isSelecting.current || isInsertingRef.current)) { return; } const { canvasElement, model, worksheetCanvas } = options; @@ -70,8 +64,6 @@ const usePointer = (options: PointerSettings): PointerEvents => { if (isSelecting.current) { options.onAreaSelecting(cell); - } else if (isExtending.current) { - options.onExtendToCell(cell); } else if (isInsertingRef.current) { const { refresh, workbookState } = options; const editingCell = workbookState.getEditingCell(); @@ -103,11 +95,6 @@ const usePointer = (options: PointerSettings): PointerEvents => { isSelecting.current = false; worksheetElement.current?.releasePointerCapture(event.pointerId); options.onAreaSelected(); - } else if (isExtending.current) { - const { worksheetElement } = options; - isExtending.current = false; - worksheetElement.current?.releasePointerCapture(event.pointerId); - options.onExtendToEnd(); } else if (isInsertingRef.current) { const { worksheetElement } = options; isInsertingRef.current = false; @@ -120,10 +107,14 @@ const usePointer = (options: PointerSettings): PointerEvents => { const onPointerDown = useCallback( (event: PointerEvent) => { const target = event.target as HTMLElement; - if (target !== null && target.className === "column-resize-handle") { + if (target.className === "column-resize-handle") { // we are resizing a column return; } + if (target.className.includes("ironcalc-cell-handle")) { + // we are extending values + return; + } let x = event.clientX; let y = event.clientY; const { @@ -251,26 +242,10 @@ const usePointer = (options: PointerSettings): PointerEvents => { [options], ); - const onPointerHandleDown = useCallback( - (event: PointerEvent) => { - const worksheetWrapper = options.worksheetElement.current; - // Silence the linter - if (!worksheetWrapper) { - return; - } - isExtending.current = true; - worksheetWrapper.setPointerCapture(event.pointerId); - event.stopPropagation(); - event.preventDefault(); - }, - [options], - ); - return { onPointerDown, onPointerMove, onPointerUp, - onPointerHandleDown, }; }; diff --git a/webapp/IronCalc/src/components/WorksheetCanvas/outlineHandle.ts b/webapp/IronCalc/src/components/WorksheetCanvas/outlineHandle.ts new file mode 100644 index 0000000..22c5eb9 --- /dev/null +++ b/webapp/IronCalc/src/components/WorksheetCanvas/outlineHandle.ts @@ -0,0 +1,211 @@ +import { AreaType } from "../workbookState"; +import { LAST_COLUMN, LAST_ROW, outlineColor } from "./constants"; +import type WorksheetCanvas from "./worksheetCanvas"; + +export function attachOutlineHandle( + worksheet: WorksheetCanvas, +): HTMLDivElement { + // There is *always* a parent + const parent = worksheet.canvas.parentElement as HTMLDivElement; + + // Remove any existing cell outline handles + for (const handle of parent.querySelectorAll(".ironcalc-cell-handle")) { + handle.remove(); + } + + // Create a new cell outline handle + const cellOutlineHandle = document.createElement("div"); + cellOutlineHandle.className = "ironcalc-cell-handle"; + parent.appendChild(cellOutlineHandle); + worksheet.cellOutlineHandle = cellOutlineHandle; + + Object.assign(cellOutlineHandle.style, { + position: "absolute", + width: "5px", + height: "5px", + background: outlineColor, + cursor: "crosshair", + borderRadius: "1px", + }); + + // cell handle events + const resizeHandleMove = (event: MouseEvent): void => { + const canvasRect = worksheet.canvas.getBoundingClientRect(); + const x = event.clientX - canvasRect.x; + const y = event.clientY - canvasRect.y; + + const cell = worksheet.getCellByCoordinates(x, y); + if (!cell) { + return; + } + const { row, column } = cell; + const { + range: [rowStart, columnStart, rowEnd, columnEnd], + } = worksheet.model.getSelectedView(); + // We are either extending by rows or by columns + // And we could be doing it in the positive direction (downwards or right) + // or the negative direction (upwards or left) + + if ( + row > rowEnd && + ((column <= columnEnd && column >= columnStart) || + (column < columnStart && columnStart - column < row - rowEnd) || + (column > columnEnd && column - columnEnd < row - rowEnd)) + ) { + // rows downwards + const area = { + type: AreaType.rowsDown, + rowStart: rowEnd + 1, + rowEnd: row, + columnStart, + columnEnd, + }; + worksheet.workbookState.setExtendToArea(area); + worksheet.renderSheet(); + } else if ( + row < rowStart && + ((column <= columnEnd && column >= columnStart) || + (column < columnStart && columnStart - column < rowStart - row) || + (column > columnEnd && column - columnEnd < rowStart - row)) + ) { + // rows upwards + const area = { + type: AreaType.rowsUp, + rowStart: row, + rowEnd: rowStart, + columnStart, + columnEnd, + }; + worksheet.workbookState.setExtendToArea(area); + worksheet.renderSheet(); + } else if ( + column > columnEnd && + ((row <= rowEnd && row >= rowStart) || + (row < rowStart && rowStart - row < column - columnEnd) || + (row > rowEnd && row - rowEnd < column - columnEnd)) + ) { + // columns right + const area = { + type: AreaType.columnsRight, + rowStart, + rowEnd, + columnStart: columnEnd + 1, + columnEnd: column, + }; + worksheet.workbookState.setExtendToArea(area); + worksheet.renderSheet(); + } else if ( + column < columnStart && + ((row <= rowEnd && row >= rowStart) || + (row < rowStart && rowStart - row < columnStart - column) || + (row > rowEnd && row - rowEnd < columnStart - column)) + ) { + // columns left + const area = { + type: AreaType.columnsLeft, + rowStart, + rowEnd, + columnStart: column, + columnEnd: columnStart, + }; + worksheet.workbookState.setExtendToArea(area); + worksheet.renderSheet(); + } + }; + + const resizeHandleUp = (_event: MouseEvent): void => { + document.removeEventListener("pointermove", resizeHandleMove); + document.removeEventListener("pointerup", resizeHandleUp); + + const { sheet, range } = worksheet.model.getSelectedView(); + const extendedArea = worksheet.workbookState.getExtendToArea(); + if (!extendedArea) { + return; + } + const rowStart = Math.min(range[0], range[2]); + const height = Math.abs(range[2] - range[0]) + 1; + const width = Math.abs(range[3] - range[1]) + 1; + const columnStart = Math.min(range[1], range[3]); + + const area = { + sheet, + row: rowStart, + column: columnStart, + width, + height, + }; + + switch (extendedArea.type) { + case AreaType.rowsDown: + worksheet.model.autoFillRows(area, extendedArea.rowEnd); + break; + case AreaType.rowsUp: { + worksheet.model.autoFillRows(area, extendedArea.rowStart); + break; + } + case AreaType.columnsRight: { + worksheet.model.autoFillColumns(area, extendedArea.columnEnd); + break; + } + case AreaType.columnsLeft: { + worksheet.model.autoFillColumns(area, extendedArea.columnStart); + break; + } + } + worksheet.model.setSelectedRange( + Math.min(rowStart, extendedArea.rowStart), + Math.min(columnStart, extendedArea.columnStart), + Math.max(rowStart + height - 1, extendedArea.rowEnd), + Math.max(columnStart + width - 1, extendedArea.columnEnd), + ); + worksheet.workbookState.clearExtendToArea(); + worksheet.renderSheet(); + }; + + cellOutlineHandle.addEventListener("pointerdown", () => { + document.addEventListener("pointermove", resizeHandleMove); + document.addEventListener("pointerup", resizeHandleUp); + }); + + cellOutlineHandle.addEventListener("dblclick", (event) => { + // On double-click, we will auto-fill the rows below the selected cell + const [sheet, row, column] = worksheet.model.getSelectedCell(); + let lastUsedRow = row + 1; + let testColumn = column - 1; + + // The "test column" is the column to the left of the selected cell or the next column if the left one is empty + if ( + testColumn < 1 || + worksheet.model.getFormattedCellValue(sheet, row, column - 1) === "" + ) { + testColumn = column + 1; + if ( + testColumn > LAST_COLUMN || + worksheet.model.getFormattedCellValue(sheet, row, testColumn) === "" + ) { + return; + } + } + + // Find the last used row in the "test column" + for (let r = row + 1; r <= LAST_ROW; r += 1) { + if (worksheet.model.getFormattedCellValue(sheet, r, testColumn) === "") { + break; + } + lastUsedRow = r; + } + + const area = { + sheet, + row: row, + column: column, + width: 1, + height: 1, + }; + + worksheet.model.autoFillRows(area, lastUsedRow); + event.stopPropagation(); + worksheet.renderSheet(); + }); + return cellOutlineHandle; +} diff --git a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts index cda2da9..0e14005 100644 --- a/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/IronCalc/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -30,7 +30,6 @@ export interface CanvasSettings { canvas: HTMLCanvasElement; cellOutline: HTMLDivElement; areaOutline: HTMLDivElement; - cellOutlineHandle: HTMLDivElement; extendToOutline: HTMLDivElement; columnGuide: HTMLDivElement; rowGuide: HTMLDivElement; @@ -55,70 +54,6 @@ export const defaultCellFontFamily = fonts.regular; export const headerFontFamily = fonts.regular; export const frozenSeparatorWidth = 3; -// Get a 10% transparency of an hex color -function hexToRGBA10Percent(colorHex: string): string { - // Remove the leading hash (#) if present - const hex = colorHex.replace(/^#/, ""); - - // Parse the hex color - const red = Number.parseInt(hex.substring(0, 2), 16); - const green = Number.parseInt(hex.substring(2, 4), 16); - const blue = Number.parseInt(hex.substring(4, 6), 16); - - // Set the alpha (opacity) to 0.1 (10%) - const alpha = 0.1; - - // Return the RGBA color string - return `rgba(${red}, ${green}, ${blue}, ${alpha})`; -} - -/** - * Splits the given text into multiple lines. If `wrapText` is true, it applies word-wrapping - * based on the specified canvas context, maximum width, and horizontal padding. - * - * - First, the text is split by newline characters so that explicit newlines are respected. - * - If wrapping is enabled, each line is further split into words and measured against the - * available width. Whenever adding an extra word would exceed - * this limit, a new line is started. - * - * @param text The text to split into lines. - * @param wrapText Whether to apply word-wrapping or just return text split by newlines. - * @param context The `CanvasRenderingContext2D` used for measuring text width. - * @param width The maximum width for each line. - * @returns An array of lines (strings), each fitting within the specified width if wrapping is enabled. - */ -function computeWrappedLines( - text: string, - wrapText: boolean, - context: CanvasRenderingContext2D, - width: number, -): string[] { - // Split the text into lines - const rawLines = text.split("\n"); - if (!wrapText) { - // If there is no wrapping, return the raw lines - return rawLines; - } - const wrappedLines = []; - for (const line of rawLines) { - const words = line.split(" "); - let currentLine = words[0]; - for (let i = 1; i < words.length; i += 1) { - const word = words[i]; - const testLine = `${currentLine} ${word}`; - const textWidth = context.measureText(testLine).width; - if (textWidth < width) { - currentLine = testLine; - } else { - wrappedLines.push(currentLine); - currentLine = word; - } - } - wrappedLines.push(currentLine); - } - return wrappedLines; -} - export default class WorksheetCanvas { sheetWidth: number; @@ -171,7 +106,6 @@ export default class WorksheetCanvas { this.refresh = options.refresh; this.cellOutline = options.elements.cellOutline; - this.cellOutlineHandle = options.elements.cellOutlineHandle; this.areaOutline = options.elements.areaOutline; this.extendToOutline = options.elements.extendToOutline; this.rowGuide = options.elements.rowGuide; @@ -181,6 +115,7 @@ export default class WorksheetCanvas { this.onColumnWidthChanges = options.onColumnWidthChanges; this.onRowHeightChanges = options.onRowHeightChanges; this.resetHeaders(); + this.cellOutlineHandle = attachOutlineHandle(this); } setScrollPosition(scrollPosition: { left: number; top: number }): void {