// This is the cell editor for IronCalc // It is also the single most difficult part of the UX. It is based on an idea of the // celebrated Polish developer 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 // // Events outside the editor might influence the editor // 1. Clicking on a different cell: // * might either terminate the editing // * or add the external cell to the formula // 2. Clicking on a sheet tab would open the new sheet or terminate editing // 3. Clicking somewhere else will finish editing // // Keyboard navigation is also fairly complex. For instance RightArrow might: // 1. End editing and navigate to the cell on the right // 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, useCallback, useEffect, useRef, useState, } from "react"; import type { WorkbookState } from "../workbookState"; import useKeyDown from "./useKeyDown"; 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 { originalText: string; onEditEnd: () => void; onTextUpdated: () => void; model: Model; workbookState: WorkbookState; type: "cell" | "formula-bar"; } const Editor = (options: EditorOptions) => { const { model, onEditEnd, onTextUpdated, originalText, workbookState, type } = options; const [text, setText] = useState(originalText); const formulaRef = useRef(null); const maskRef = useRef(null); const textareaRef = useRef(null); useEffect(() => { setText(originalText); if (textareaRef.current) { textareaRef.current.value = originalText; } }, [originalText]); const { onKeyDown } = useKeyDown({ model, onEditEnd, onTextUpdated, workbookState, textareaRef, }); useEffect(() => { const cell = workbookState.getEditingCell(); if (!cell) { return; } const { editorWidth, editorHeight } = cell; if (formulaRef.current) { const scrollWidth = formulaRef.current.scrollWidth; if (scrollWidth > editorWidth - 5) { cell.editorWidth = scrollWidth + 10; } const scrollHeight = formulaRef.current.scrollHeight; if (scrollHeight > editorHeight) { cell.editorHeight = scrollHeight; } } if (type === cell.focus) { textareaRef.current?.focus(); } }); 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, value); if (value === "" && type === "cell") { // 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); onTextUpdated(); // Should we stop propagations? // event.stopPropagation(); // event.preventDefault(); }, [workbookState, model, onTextUpdated, type]); const onBlur = useCallback(() => { const cell = workbookState.getEditingCell(); if (type !== cell?.focus) { // If the onBlur event is called because we switch from the cell editor to the formula editor // or vice versa, do nothing return; } if (textareaRef.current) { textareaRef.current.value = ""; } // This happens if the blur hasn't been taken care before by // onclick or onpointerdown events // If we are editing a cell finish that if (cell) { model.setUserInput( cell.sheet, cell.row, cell.column, workbookState.getEditingText(), ); workbookState.clearEditingCell(); } onEditEnd(); }, [model, workbookState, onEditEnd, type]); const cell = workbookState.getEditingCell(); const showEditor = cell !== null || type === "formula-bar" ? "block" : "none"; const mtext = cell ? workbookState.getEditingText() : originalText; const styledFormula = getFormulaHTML(model, mtext).html; return (
{styledFormula}