Files
IronCalc/webapp/src/components/editor/editor.tsx
2024-10-15 19:29:21 +02:00

256 lines
7.8 KiB
TypeScript

// 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<HTMLDivElement>(null);
const maskRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
display: showEditor,
background: "#FFF",
fontFamily: "Inter",
fontSize: "13px",
}}
>
<div
ref={maskRef}
style={{
...commonCSS,
textAlign: "left",
pointerEvents: "none",
height: "100%",
}}
>
<div
style={{
display: "inline-block",
}}
ref={formulaRef}
>
{styledFormula}
</div>
</div>
<textarea
ref={textareaRef}
rows={1}
style={{
...commonCSS,
color: "transparent",
backgroundColor: "transparent",
caretColor,
outline: "none",
resize: "none",
border: "none",
height: "100%",
overflow: "hidden",
alignContent: "baseline",
}}
defaultValue={text}
spellCheck="false"
onKeyDown={onKeyDown}
onChange={onChange}
onBlur={onBlur}
onPointerDown={(event) => {
// We are either clicking in the same cell we are editing,
// in which case we just change the mode to edit, or we click
// in a different editor, in which case we switch the focus
const cell = workbookState.getEditingCell();
if (cell) {
// We make sure the mode is edit
cell.mode = "edit";
cell.focus = type;
workbookState.setEditingCell(cell);
event.stopPropagation();
}
}}
onScroll={() => {
if (maskRef.current && textareaRef.current) {
maskRef.current.style.left = `-${textareaRef.current.scrollLeft}px`;
maskRef.current.style.top = `-${textareaRef.current.scrollTop}px`;
}
}}
/>
</div>
);
};
export default Editor;