From f53b39b220f00945b81520b53b13cafd0962ea13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Sat, 24 Aug 2024 06:05:03 +0200 Subject: [PATCH] UPDATE: Adds cell and formula editing --- webapp/src/App.css | 3 +- webapp/src/App.tsx | 2 + .../WorksheetCanvas/worksheetCanvas.ts | 75 +++++- webapp/src/components/editor/editor.tsx | 243 ++++++++++++++++++ webapp/src/components/editor/util.tsx | 189 ++++++++++++++ webapp/src/components/formulaDialog.tsx | 50 ---- webapp/src/components/formulabar.tsx | 75 ++++-- webapp/src/components/workbook.tsx | 57 +++- webapp/src/components/workbookState.ts | 81 ++++++ webapp/src/components/worksheet.tsx | 75 +++++- webapp/src/index.css | 1 + 11 files changed, 759 insertions(+), 92 deletions(-) create mode 100644 webapp/src/components/editor/editor.tsx create mode 100644 webapp/src/components/editor/util.tsx delete mode 100644 webapp/src/components/formulaDialog.tsx diff --git a/webapp/src/App.css b/webapp/src/App.css index 4e77e42..7b24a13 100644 --- a/webapp/src/App.css +++ b/webapp/src/App.css @@ -1,6 +1,7 @@ #root { position: absolute; - inset: 10px; + inset: 0px; + margin: 10px; border: 1px solid #aaa; border-radius: 4px; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index bc19969..9fe79b3 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -11,6 +11,7 @@ function App() { const [workbookState, setWorkbookState] = useState( null, ); + useEffect(() => { async function start() { await init(); @@ -42,6 +43,7 @@ function App() { // We could use context for model, but the problem is that it should initialized to null. // Passing the property down makes sure it is always defined. + return ; } diff --git a/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts b/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts index 4c5d00b..cb5e9a8 100644 --- a/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -30,6 +30,7 @@ export interface CanvasSettings { columnGuide: HTMLDivElement; rowGuide: HTMLDivElement; columnHeaders: HTMLDivElement; + editor: HTMLDivElement; }; onColumnWidthChanges: (sheet: number, column: number, width: number) => void; onRowHeightChanges: (sheet: number, row: number, height: number) => void; @@ -48,6 +49,23 @@ 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})`; +} + export default class WorksheetCanvas { sheetWidth: number; @@ -61,6 +79,8 @@ export default class WorksheetCanvas { canvas: HTMLCanvasElement; + editor: HTMLDivElement; + areaOutline: HTMLDivElement; cellOutline: HTMLDivElement; @@ -92,6 +112,7 @@ export default class WorksheetCanvas { this.height = options.height; this.ctx = this.setContext(); this.workbookState = options.workbookState; + this.editor = options.elements.editor; this.cellOutline = options.elements.cellOutline; this.cellOutlineHandle = options.elements.cellOutlineHandle; @@ -1092,7 +1113,7 @@ export default class WorksheetCanvas { this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding; const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding; - const { cellOutline, areaOutline, cellOutlineHandle } = this; + const { cellOutline, editor, areaOutline, cellOutlineHandle } = this; const cellEditing = null; cellOutline.style.visibility = "visible"; @@ -1105,6 +1126,11 @@ export default class WorksheetCanvas { cellOutlineHandle.style.visibility = "hidden"; } + editor.style.left = `${x + 3}px`; + editor.style.top = `${y + 3}px`; + editor.style.width = `${width - 1}px`; + editor.style.height = `${height - 1}px`; + // Position the cell outline and clip it cellOutline.style.left = `${x - padding}px`; cellOutline.style.top = `${y - padding}px`; @@ -1214,6 +1240,52 @@ export default class WorksheetCanvas { cellOutlineHandle.style.top = `${handleY - handleHeight / 2}px`; } + private drawActiveRanges(topLeftCell: Cell, bottomRightCell: Cell): void { + const activeRanges = this.workbookState.getActiveRanges(); + const activeRangesCount = activeRanges.length; + const ctx = this.ctx; + ctx.setLineDash([2, 2]); + for (let rangeIndex = 0; rangeIndex < activeRangesCount; rangeIndex += 1) { + const range = activeRanges[rangeIndex]; + + const allowedOffset = 1; // to make borders look nicer + const minRow = topLeftCell.row - allowedOffset; + const maxRow = bottomRightCell.row + allowedOffset; + const minColumn = topLeftCell.column - allowedOffset; + const maxColumn = bottomRightCell.column + allowedOffset; + + if ( + minRow <= range.rowEnd && + range.rowStart <= maxRow && + minColumn <= range.columnEnd && + range.columnStart < maxColumn + ) { + // Range in the viewport. + const displayRange: typeof range = { + ...range, + rowStart: Math.max(minRow, range.rowStart), + rowEnd: Math.min(maxRow, range.rowEnd), + columnStart: Math.max(minColumn, range.columnStart), + columnEnd: Math.min(maxColumn, range.columnEnd), + }; + const [xStart, yStart] = this.getCoordinatesByCell( + displayRange.rowStart, + displayRange.columnStart, + ); + const [xEnd, yEnd] = this.getCoordinatesByCell( + displayRange.rowEnd + 1, + displayRange.columnEnd + 1, + ); + ctx.strokeStyle = range.color; + ctx.lineWidth = 1; + ctx.strokeRect(xStart, yStart, xEnd - xStart, yEnd - yStart); + ctx.fillStyle = hexToRGBA10Percent(range.color); + ctx.fillRect(xStart, yStart, xEnd - xStart, yEnd - yStart); + } + } + + ctx.setLineDash([]); + } renderSheet(): void { console.time("renderSheet"); this._renderSheet(); @@ -1352,5 +1424,6 @@ export default class WorksheetCanvas { this.drawCellOutline(); this.drawExtendToArea(); + this.drawActiveRanges(topLeftCell, bottomRightCell); } } diff --git a/webapp/src/components/editor/editor.tsx b/webapp/src/components/editor/editor.tsx new file mode 100644 index 0000000..56f4703 --- /dev/null +++ b/webapp/src/components/editor/editor.tsx @@ -0,0 +1,243 @@ +// This is the cell editor for IronCalc +// It is also the most difficult part of the UX. It is based on an idea of Mateusz Kopec. +// There is a hidden texarea and we only show the caret. What we see is a div with the same text content +// but in HTML so we can have different colors. +// Some keystrokes have different behaviour than a raw HTML text area. +// For those cases we capture the keydown event and stop its propagation. +// As the editor changes content we need to propagate those changes so the spreadsheet can +// mark with colors the active ranges or update the formula in the formula bar + +import type { Model } from "@ironcalc/wasm"; +import { + type CSSProperties, + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import type { WorkbookState } from "../workbookState"; +import getFormulaHTML from "./util"; + +const commonCSS: CSSProperties = { + fontWeight: "inherit", + fontFamily: "inherit", + fontSize: "inherit", + position: "absolute", + left: 0, + top: 0, + whiteSpace: "pre", + width: "100%", + padding: 0, + lineHeight: "22px", +}; + +const caretColor = "#FF8899"; + +interface EditorOptions { + minimalWidth: number | string; + minimalHeight: number | string; + display: boolean; + expand: boolean; + originalText: string; + onEditEnd: () => void; + onTextUpdated: () => void; + model: Model; + workbookState: WorkbookState; + type: "cell" | "formula-bar"; +} + +const Editor = (options: EditorOptions) => { + const { + display, + expand, + minimalHeight, + minimalWidth, + model, + onEditEnd, + onTextUpdated, + originalText, + workbookState, + type, + } = options; + + const [width, setWidth] = useState(minimalWidth); + const [height, setHeight] = useState(minimalHeight); + const [text, setText] = useState(originalText); + const [styledFormula, setStyledFormula] = useState( + getFormulaHTML(model, text).html, + ); + + const formulaRef = useRef(null); + const maskRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + setText(originalText); + setStyledFormula(getFormulaHTML(model, originalText).html); + if (textareaRef.current) { + textareaRef.current.value = originalText; + } + }, [originalText, model]); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + const { key, shiftKey, altKey } = event; + const textarea = textareaRef.current; + if (!textarea) { + return; + } + switch (key) { + case "Enter": { + if (altKey) { + // new line + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const newText = `${text.slice(0, start)}\n${text.slice(end)}`; + setText(newText); + setTimeout(() => { + textarea.setSelectionRange(start + 1, start + 1); + }, 0); + event.stopPropagation(); + event.preventDefault(); + return; + } + // end edit and select cell bellow + setTimeout(() => { + const cell = workbookState.getEditingCell(); + if (cell) { + workbookState.clearEditingCell(); + model.setUserInput(cell.sheet, cell.row, cell.column, cell.text); + const sign = shiftKey ? -1 : 1; + model.setSelectedCell(cell.row + sign, cell.column); + } + onEditEnd(); + }, 0); + // event bubbles up + return; + } + case "Tab": { + // end edit and select cell to the right + const cell = workbookState.getEditingCell(); + if (cell) { + workbookState.clearEditingCell(); + model.setUserInput(cell.sheet, cell.row, cell.column, cell.text); + const sign = shiftKey ? -1 : 1; + model.setSelectedCell(cell.row, cell.column + sign); + if (textareaRef.current) { + textareaRef.current.value = ""; + setStyledFormula(getFormulaHTML(model, "").html); + } + event.stopPropagation(); + event.preventDefault(); + } + onEditEnd(); + return; + } + case "Escape": { + // quit editing without modifying the cell + workbookState.clearEditingCell(); + onEditEnd(); + return; + } + // TODO: Arrow keys navigate in Excel + case "ArrowRight": { + return; + } + default: { + // We run this in a timeout because the value is not yet in the textarea + // since we are capturing the keydown event + setTimeout(() => { + const value = textarea.value; + const styledFormula = getFormulaHTML(model, value); + const cell = workbookState.getEditingCell(); + if (cell) { + cell.text = value; + workbookState.setEditingCell(cell); + + workbookState.setActiveRanges(styledFormula.activeRanges); + setStyledFormula(styledFormula.html); + + onTextUpdated(); + } + }, 0); + } + } + }, + [model, text, onEditEnd, onTextUpdated, workbookState], + ); + + useEffect(() => { + if (display) { + textareaRef.current?.focus(); + } + }, [display]); + + const onChange = useCallback(() => { + if (textareaRef.current) { + textareaRef.current.value = ""; + setStyledFormula(getFormulaHTML(model, "").html); + } + + // This happens if the blur hasn't been taken care before by + // onclick or onpointerdown events + // If we are editing a cell finish that + const cell = workbookState.getEditingCell(); + if (cell) { + workbookState.clearEditingCell(); + model.setUserInput(cell.sheet, cell.row, cell.column, cell.text); + } + onEditEnd(); + }, [model, workbookState, onEditEnd]); + + const isCellEditing = workbookState.getEditingCell() !== null; + + const showEditor = + isCellEditing && (display || type === "formula-bar") ? "block" : "none"; + + return ( +
+
+
{styledFormula}
+
+