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 {