FIX: Cell editor correct behaviour

This commit is contained in:
Nicolás Hatcher
2024-10-10 18:38:47 +02:00
committed by Nicolás Hatcher Andrés
parent f26cdd3a4b
commit 42c1a39131
7 changed files with 497 additions and 110 deletions

View File

@@ -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();
}
}}

View File

@@ -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<HTMLTextAreaElement>;
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;

View File

@@ -52,6 +52,7 @@ function FormulaBar(properties: FormulaBarProps) {
cursorEnd: formulaValue.length,
focus: "formula-bar",
activeRanges: [],
mode: "accept",
});
event.stopPropagation();
event.preventDefault();

View File

@@ -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);
},

View File

@@ -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

View File

@@ -339,6 +339,7 @@ function Worksheet(props: {
focus: "cell",
referencedRange: null,
activeRanges: [],
mode: "accept",
});
setOriginalText(text);
event.stopPropagation();