From 42c1a39131f514ed273eb5c5086aa013b9653d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Thu, 10 Oct 2024 18:38:47 +0200 Subject: [PATCH] FIX: Cell editor correct behaviour --- webapp/src/components/editor/editor.tsx | 172 +++------ webapp/src/components/editor/useKeyDown.ts | 423 +++++++++++++++++++++ webapp/src/components/formulabar.tsx | 1 + webapp/src/components/workbook.tsx | 3 + webapp/src/components/workbookState.ts | 5 + webapp/src/components/worksheet.tsx | 1 + webapp/src/icons/index.ts | 2 +- 7 files changed, 497 insertions(+), 110 deletions(-) create mode 100644 webapp/src/components/editor/useKeyDown.ts diff --git a/webapp/src/components/editor/editor.tsx b/webapp/src/components/editor/editor.tsx index a690406..7421dc3 100644 --- a/webapp/src/components/editor/editor.tsx +++ b/webapp/src/components/editor/editor.tsx @@ -19,16 +19,32 @@ // 2. Move the cursor to the right // 3. Insert in the formula the cell name on the right +// You can either be editing a formula or content. +// When editing content (behaviour is common to Excel and Google Sheets): +// * If you start editing by typing you are in *accept* mode +// * If you start editing by F2 you are in *cruise* mode +// * If you start editing by double click you are in *cruise* mode +// In Google Sheets "Enter" starts editing and puts you in *cruise* mode. We do not do that +// Once you are in cruise mode it is not possible to switch to accept mode +// The only way to go from accept mode to cruise mode is clicking in the content somewhere + +// When editing a formula. +// In Google Sheets you are either in insert mode or cruise mode. +// You can get back to accept mode if you delete the whole formula +// In Excel you can be either in insert or accept but if you click in the formula body +// you switch to cruise mode. Once in cruise mode you can go to insert mode by selecting a range. +// Then you are back in accept/insert modes + import type { Model } from "@ironcalc/wasm"; import { type CSSProperties, - type KeyboardEvent, useCallback, useEffect, useRef, useState, } from "react"; import type { WorkbookState } from "../workbookState"; +import useKeyDown from "./useKeyDown"; import getFormulaHTML from "./util"; const commonCSS: CSSProperties = { @@ -92,113 +108,16 @@ const Editor = (options: EditorOptions) => { } }, [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) { - model.setUserInput( - cell.sheet, - cell.row, - cell.column, - cell.text + (cell.referencedRange?.str || ""), - ); - const sign = shiftKey ? -1 : 1; - model.setSelectedSheet(cell.sheet); - model.setSelectedCell(cell.row + sign, cell.column); - workbookState.clearEditingCell(); - } - 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 + (cell.referencedRange?.str || ""), - ); - const sign = shiftKey ? -1 : 1; - model.setSelectedSheet(cell.sheet); - 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 - const cell = workbookState.getEditingCell(); - if (cell) { - model.setSelectedSheet(cell.sheet); - } - 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 cell = workbookState.getEditingCell(); - if (cell) { - // accept whatever is in the referenced range - const value = textarea.value; - const styledFormula = getFormulaHTML(model, value, ""); - - cell.text = value; - cell.referencedRange = null; - cell.cursorStart = textarea.selectionStart; - cell.cursorEnd = textarea.selectionEnd; - workbookState.setEditingCell(cell); - - workbookState.setActiveRanges(styledFormula.activeRanges); - setStyledFormula(styledFormula.html); - - onTextUpdated(); - } - }, 0); - } - } - }, - [model, text, onEditEnd, onTextUpdated, workbookState], - ); + const { onKeyDown } = useKeyDown({ + model, + text, + onEditEnd, + onTextUpdated, + workbookState, + textareaRef, + setStyledFormula, + setText, + }); useEffect(() => { if (display) { @@ -207,6 +126,35 @@ const Editor = (options: EditorOptions) => { }, [display]); const onChange = useCallback(() => { + const textarea = textareaRef.current; + const cell = workbookState.getEditingCell(); + if (!textarea || !cell) { + return; + } + const value = textarea.value; + cell.text = value; + cell.referencedRange = null; + cell.cursorStart = textarea.selectionStart; + cell.cursorEnd = textarea.selectionEnd; + const styledFormula = getFormulaHTML(model, cell.text, ""); + if (value === "") { + // When we delete the content of a cell we jump to accept mode + cell.mode = "accept"; + } + workbookState.setEditingCell(cell); + + workbookState.setActiveRanges(styledFormula.activeRanges); + setText(cell.text); + setStyledFormula(styledFormula.html); + + onTextUpdated(); + + // Should we stop propagations? + // event.stopPropagation(); + // event.preventDefault(); + }, [workbookState, model, onTextUpdated]); + + const onBlur = useCallback(() => { if (textareaRef.current) { textareaRef.current.value = ""; setStyledFormula(getFormulaHTML(model, "", "").html); @@ -272,10 +220,16 @@ const Editor = (options: EditorOptions) => { defaultValue={text} spellCheck="false" onKeyDown={onKeyDown} - onBlur={onChange} + onChange={onChange} + onBlur={onBlur} onClick={(event) => { // Prevents this from bubbling up and focusing on the spreadsheet if (isCellEditing && type === "cell") { + const cell = workbookState.getEditingCell(); + if (cell) { + cell.mode = "edit"; + workbookState.setEditingCell(cell); + } event.stopPropagation(); } }} diff --git a/webapp/src/components/editor/useKeyDown.ts b/webapp/src/components/editor/useKeyDown.ts new file mode 100644 index 0000000..c168e17 --- /dev/null +++ b/webapp/src/components/editor/useKeyDown.ts @@ -0,0 +1,423 @@ +import type { Model } from "@ironcalc/wasm"; +import { type KeyboardEvent, type RefObject, useCallback } from "react"; +import { rangeToStr } from "../util"; +import type { WorkbookState } from "../workbookState"; +import getFormulaHTML, { isInReferenceMode } from "./util"; + +interface Options { + model: Model; + text: string; + onEditEnd: () => void; + onTextUpdated: () => void; + workbookState: WorkbookState; + textareaRef: RefObject; + setText: (s: string) => void; + setStyledFormula: (html: JSX.Element[]) => void; +} + +export const useKeyDown = ( + options: Options, +): { onKeyDown: (event: KeyboardEvent) => void } => { + const { + model, + text, + onEditEnd, + onTextUpdated, + workbookState, + textareaRef, + setText, + setStyledFormula, + } = options; + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + const { key, shiftKey, altKey } = event; + const textarea = textareaRef.current; + const cell = workbookState.getEditingCell(); + if (!textarea || !cell) { + 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; + } + event.stopPropagation(); + event.preventDefault(); + // end edit and select cell bellow (or above if shiftKey) + model.setUserInput( + cell.sheet, + cell.row, + cell.column, + cell.text + (cell.referencedRange?.str || ""), + ); + const sign = shiftKey ? -1 : 1; + model.setSelectedSheet(cell.sheet); + model.setSelectedCell(cell.row + sign, cell.column); + workbookState.clearEditingCell(); + onEditEnd(); + return; + } + case "Tab": { + // end edit and select cell to the right (or left if ShiftKey) + workbookState.clearEditingCell(); + model.setUserInput( + cell.sheet, + cell.row, + cell.column, + cell.text + (cell.referencedRange?.str || ""), + ); + const sign = shiftKey ? -1 : 1; + model.setSelectedSheet(cell.sheet); + 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 + const cell = workbookState.getEditingCell(); + if (cell) { + model.setSelectedSheet(cell.sheet); + } + workbookState.clearEditingCell(); + onEditEnd(); + return; + } + // TODO: Arrow keys navigate in Excel + case "ArrowRight": { + if (cell.mode === "edit") { + // just edit + return; + } + event.stopPropagation(); + event.preventDefault(); + + if (cell.referencedRange) { + // There is already a reference range we move it to the right + // (or expand if shift is pressed) + const sheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const range = cell.referencedRange.range; + if (shiftKey) { + range.columnEnd += 1; + } else { + const column = range.columnStart + 1; + const row = range.rowStart; + range.columnStart = column; + range.columnEnd = column; + range.rowEnd = row; + } + cell.referencedRange = { + range, + str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]), + }; + workbookState.setEditingCell(cell); + onTextUpdated(); + return; + } + if (isInReferenceMode(cell.text, cell.cursorStart)) { + // there is not a referenced Range but we are in reference mode + // we select the next cell + const sheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const range = { + sheet: cell.sheet, + rowStart: cell.row, + rowEnd: cell.row, + columnStart: cell.column + 1, + columnEnd: cell.column + 1, + }; + cell.referencedRange = { + range, + str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]), + }; + workbookState.setEditingCell(cell); + onTextUpdated(); + return; + } + // at this point we finish editing and select the cell to the right + // (or left if ShiftKey is pressed) + workbookState.clearEditingCell(); + model.setUserInput(cell.sheet, cell.row, cell.column, cell.text); + model.setSelectedSheet(cell.sheet); + if (shiftKey) { + // TODO: ShiftKey + } else { + model.setSelectedCell(cell.row, cell.column + 1); + } + if (textareaRef.current) { + textareaRef.current.value = ""; + setStyledFormula(getFormulaHTML(model, "", "").html); + } + + onEditEnd(); + return; + } + case "ArrowLeft": { + if (cell.mode === "edit") { + return; + } + event.stopPropagation(); + event.preventDefault(); + if (cell.referencedRange) { + // There is already a reference range we move it to the right + // (or expand if shift is pressed) + const sheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const range = cell.referencedRange.range; + if (shiftKey) { + range.columnEnd -= 1; + } else { + const column = range.columnStart - 1; + const row = range.rowStart; + range.columnStart = column; + range.columnEnd = column; + range.rowEnd = row; + } + cell.referencedRange = { + range, + str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]), + }; + workbookState.setEditingCell(cell); + onTextUpdated(); + return; + } + if (isInReferenceMode(cell.text, cell.cursorStart)) { + // there is not a referenced Range but we are in reference mode + // we select the next cell + const sheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const range = { + sheet: cell.sheet, + rowStart: cell.row, + rowEnd: cell.row, + columnStart: cell.column - 1, + columnEnd: cell.column - 1, + }; + cell.referencedRange = { + range, + str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]), + }; + workbookState.setEditingCell(cell); + onTextUpdated(); + return; + } + // at this point we finish editing and select the cell to the right + // (or left if ShiftKey is pressed) + workbookState.clearEditingCell(); + model.setUserInput(cell.sheet, cell.row, cell.column, cell.text); + model.setSelectedSheet(cell.sheet); + if (shiftKey) { + // TODO: ShiftKey + } else { + model.setSelectedCell(cell.row, cell.column - 1); + } + if (textareaRef.current) { + textareaRef.current.value = ""; + setStyledFormula(getFormulaHTML(model, "", "").html); + } + + onEditEnd(); + return; + } + case "ArrowUp": { + if (cell.mode === "edit") { + return; + } + event.stopPropagation(); + event.preventDefault(); + if (cell.referencedRange) { + // There is already a reference range we move it to the right + // (or expand if shift is pressed) + const sheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const range = cell.referencedRange.range; + if (shiftKey) { + if (range.rowEnd > range.rowStart) { + range.rowEnd -= 1; + } else { + range.rowStart -= 1; + } + } else { + const column = range.columnStart; + const row = range.rowStart - 1; + range.columnStart = column; + range.columnEnd = column; + range.rowStart = row; + range.rowEnd = row; + } + cell.referencedRange = { + range, + str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]), + }; + workbookState.setEditingCell(cell); + onTextUpdated(); + return; + } + if (isInReferenceMode(cell.text, cell.cursorStart)) { + // there is not a referenced Range but we are in reference mode + // we select the next cell + const sheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const range = { + sheet: cell.sheet, + rowStart: cell.row - 1, + rowEnd: cell.row - 1, + columnStart: cell.column, + columnEnd: cell.column, + }; + cell.referencedRange = { + range, + str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]), + }; + workbookState.setEditingCell(cell); + onTextUpdated(); + return; + } + // at this point we finish editing and select the cell to the right + // (or left if ShiftKey is pressed) + workbookState.clearEditingCell(); + model.setUserInput(cell.sheet, cell.row, cell.column, cell.text); + model.setSelectedSheet(cell.sheet); + if (shiftKey) { + // TODO: ShiftKey + } else { + model.setSelectedCell(cell.row - 1, cell.column); + } + if (textareaRef.current) { + textareaRef.current.value = ""; + setStyledFormula(getFormulaHTML(model, "", "").html); + } + + onEditEnd(); + return; + } + case "ArrowDown": { + if (cell.mode === "edit") { + return; + } + event.stopPropagation(); + event.preventDefault(); + if (cell.referencedRange) { + // There is already a reference range we move it to the right + // (or expand if shift is pressed) + const sheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const range = cell.referencedRange.range; + if (shiftKey) { + range.rowEnd += 1; + } else { + const column = range.columnStart; + const row = range.rowStart + 1; + range.columnStart = column; + range.columnEnd = column; + range.rowStart = row; + range.rowEnd = row; + } + cell.referencedRange = { + range, + str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]), + }; + workbookState.setEditingCell(cell); + onTextUpdated(); + return; + } + if (isInReferenceMode(cell.text, cell.cursorStart)) { + // there is not a referenced Range but we are in reference mode + // we select the next cell + const sheetNames = model + .getWorksheetsProperties() + .map((s) => s.name); + const range = { + sheet: cell.sheet, + rowStart: cell.row + 1, + rowEnd: cell.row + 1, + columnStart: cell.column, + columnEnd: cell.column, + }; + cell.referencedRange = { + range, + str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]), + }; + workbookState.setEditingCell(cell); + onTextUpdated(); + return; + } + // at this point we finish editing and select the cell to the right + // (or left if ShiftKey is pressed) + workbookState.clearEditingCell(); + model.setUserInput(cell.sheet, cell.row, cell.column, cell.text); + model.setSelectedSheet(cell.sheet); + if (shiftKey) { + // TODO: ShiftKey + } else { + model.setSelectedCell(cell.row + 1, cell.column); + } + if (textareaRef.current) { + textareaRef.current.value = ""; + setStyledFormula(getFormulaHTML(model, "", "").html); + } + + onEditEnd(); + return; + } + case "Shift": { + return; + } + case "PageDown": + case "PageUp": { + // TODO: We can do something similar to what we do with navigation keys + event.stopPropagation(); + event.preventDefault(); + return; + } + case "End": + case "Home": { + // Excel does something similar to what we do with navigation keys + cell.mode = "edit"; + workbookState.setEditingCell(cell); + return; + } + default: { + // noop + } + } + }, + [ + model, + text, + setText, + setStyledFormula, + onEditEnd, + onTextUpdated, + workbookState, + textareaRef.current, + ], + ); + return { onKeyDown }; +}; + +export default useKeyDown; diff --git a/webapp/src/components/formulabar.tsx b/webapp/src/components/formulabar.tsx index 745b7a9..8882927 100644 --- a/webapp/src/components/formulabar.tsx +++ b/webapp/src/components/formulabar.tsx @@ -52,6 +52,7 @@ function FormulaBar(properties: FormulaBarProps) { cursorEnd: formulaValue.length, focus: "formula-bar", activeRanges: [], + mode: "accept", }); event.stopPropagation(); event.preventDefault(); diff --git a/webapp/src/components/workbook.tsx b/webapp/src/components/workbook.tsx index f1ca20f..8f06c1c 100644 --- a/webapp/src/components/workbook.tsx +++ b/webapp/src/components/workbook.tsx @@ -154,10 +154,12 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { focus: "cell", referencedRange: null, activeRanges: [], + mode: "accept", }); setRedrawId((id) => id + 1); }, onCellEditStart: (): void => { + // User presses F2, we start editing at the edn of the text const { sheet, row, column } = model.getSelectedView(); const text = model.getCellContent(sheet, row, column); workbookState.setEditingCell({ @@ -170,6 +172,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { referencedRange: null, focus: "cell", activeRanges: [], + mode: "edit", }); setRedrawId((id) => id + 1); }, diff --git a/webapp/src/components/workbookState.ts b/webapp/src/components/workbookState.ts index 15affa7..215c15c 100644 --- a/webapp/src/components/workbookState.ts +++ b/webapp/src/components/workbookState.ts @@ -52,6 +52,10 @@ export interface ReferencedRange { } type Focus = "cell" | "formula-bar"; +type EditorMode = "accept" | "edit"; + +// In "edit" mode arrow keys will move you around the text in the editor +// In "accept" mode arrow keys will accept the content and move to the next cell or select another cell // The cell that we are editing export interface EditingCell { @@ -67,6 +71,7 @@ export interface EditingCell { referencedRange: ReferencedRange | null; focus: Focus; activeRanges: ActiveRange[]; + mode: EditorMode; } // Those are styles that are copied diff --git a/webapp/src/components/worksheet.tsx b/webapp/src/components/worksheet.tsx index 3f15d60..a67f009 100644 --- a/webapp/src/components/worksheet.tsx +++ b/webapp/src/components/worksheet.tsx @@ -339,6 +339,7 @@ function Worksheet(props: { focus: "cell", referencedRange: null, activeRanges: [], + mode: "accept", }); setOriginalText(text); event.stopPropagation(); diff --git a/webapp/src/icons/index.ts b/webapp/src/icons/index.ts index 4f5700a..10773a3 100644 --- a/webapp/src/icons/index.ts +++ b/webapp/src/icons/index.ts @@ -20,8 +20,8 @@ import InsertColumnRightIcon from "./insert-column-right.svg?react"; import InsertRowAboveIcon from "./insert-row-above.svg?react"; import InsertRowBelow from "./insert-row-below.svg?react"; -import IronCalcLogo from "./orange+black.svg?react"; import IronCalcIcon from "./ironcalc_icon.svg?react"; +import IronCalcLogo from "./orange+black.svg?react"; import Fx from "./fx.svg?react";