FIX: Diverse fixes to the editor

* Editor now expands as you write
* You can switch between the formula bar and cell editor
* While editing in the formula bar you see the results in the editor
* Give Mateusz more credit
This commit is contained in:
Nicolás Hatcher
2024-10-12 13:42:33 +02:00
committed by Nicolás Hatcher Andrés
parent 248ef66e7c
commit 585e594d8d
7 changed files with 170 additions and 93 deletions

View File

@@ -16,3 +16,6 @@ export const outlineBackgroundColor = "#F2994A1A";
export const LAST_COLUMN = 16_384; export const LAST_COLUMN = 16_384;
export const LAST_ROW = 1_048_576; export const LAST_ROW = 1_048_576;
export const ROW_HEIGH_SCALE = 1.25;
export const COLUMN_WIDTH_SCALE = 1.25;

View File

@@ -3,8 +3,10 @@ import { columnNameFromNumber } from "@ironcalc/wasm";
import type { Cell } from "../types"; import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState"; import type { WorkbookState } from "../workbookState";
import { import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN, LAST_COLUMN,
LAST_ROW, LAST_ROW,
ROW_HEIGH_SCALE,
defaultTextColor, defaultTextColor,
gridColor, gridColor,
gridSeparatorColor, gridSeparatorColor,
@@ -1089,35 +1091,76 @@ export default class WorksheetCanvas {
} }
private getColumnWidth(sheet: number, column: number): number { private getColumnWidth(sheet: number, column: number): number {
return Math.round(this.model.getColumnWidth(sheet, column) * 1.25); return Math.round(
this.model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE,
);
} }
private getRowHeight(sheet: number, row: number): number { private getRowHeight(sheet: number, row: number): number {
return Math.round(this.model.getRowHeight(sheet, row) * 1.25); return Math.round(this.model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE);
}
private drawCellEditor(): void {
const cell = this.workbookState.getEditingCell();
const [selectedSheet, selectedRow, selectedColumn] =
this.model.getSelectedCell();
const { editor } = this;
if (!cell || cell.sheet !== selectedSheet) {
// If the editing cell is not in the same sheet as the selected sheet
// we take the editor out of view
editor.style.left = "-9999px";
editor.style.top = "-9999px";
return;
}
const { sheet, row, column } = cell;
// const style = this.model.getCellStyle(
// selectedSheet,
// selectedRow,
// selectedColumn
// );
// cellOutline.style.fontWeight = style.font.b ? "bold" : "normal";
// cellOutline.style.fontStyle = style.font.i ? "italic" : "normal";
// cellOutline.style.backgroundColor = style.fill.fg_color;
// TODO: Should we add the same color as the text?
// Only if it is not a formula?
// cellOutline.style.color = style.font.color;
const [x, y] = this.getCoordinatesByCell(row, column);
const padding = -1;
const width = cell.editorWidth + 2 * padding;
const height = cell.editorHeight + 2 * padding;
// const width =
// this.getColumnWidth(sheet, column) + 2 * padding;
// const height = this.getRowHeight(sheet, row) + 2 * padding;
editor.style.left = `${x}px`;
editor.style.top = `${y}px`;
editor.style.width = `${width - 1}px`;
editor.style.height = `${height - 1}px`;
} }
private drawCellOutline(): void { private drawCellOutline(): void {
const { cellOutline, areaOutline, cellOutlineHandle } = this;
if (this.workbookState.getEditingCell()) {
cellOutline.style.visibility = "hidden";
cellOutlineHandle.style.visibility = "hidden";
areaOutline.style.visibility = "hidden";
return;
}
cellOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible";
areaOutline.style.visibility = "visible";
const [selectedSheet, selectedRow, selectedColumn] = const [selectedSheet, selectedRow, selectedColumn] =
this.model.getSelectedCell(); this.model.getSelectedCell();
const { topLeftCell } = this.getVisibleCells(); const { topLeftCell } = this.getVisibleCells();
const frozenRows = this.model.getFrozenRowsCount(selectedSheet); const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet); const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
const [x, y] = this.getCoordinatesByCell(selectedRow, selectedColumn); const [x, y] = this.getCoordinatesByCell(selectedRow, selectedColumn);
const style = this.model.getCellStyle(
selectedSheet,
selectedRow,
selectedColumn,
);
const padding = -1; const padding = -1;
const width = const width =
this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding; this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding;
const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding; const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding;
const { cellOutline, editor, areaOutline, cellOutlineHandle } = this;
const cellEditing = null;
cellOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible";
if ( if (
(selectedRow < topLeftCell.row && selectedRow > frozenRows) || (selectedRow < topLeftCell.row && selectedRow > frozenRows) ||
(selectedColumn < topLeftCell.column && selectedColumn > frozenColumns) (selectedColumn < topLeftCell.column && selectedColumn > frozenColumns)
@@ -1126,19 +1169,6 @@ export default class WorksheetCanvas {
cellOutlineHandle.style.visibility = "hidden"; cellOutlineHandle.style.visibility = "hidden";
} }
if (this.workbookState.getEditingCell()?.sheet === selectedSheet) {
editor.style.left = `${x + 3}px`;
editor.style.top = `${y + 3}px`;
} else {
// If the editing cell is not in the same sheet as the selected sheet
// we take the editor out of view
editor.style.left = "-9999px";
editor.style.top = "-9999px";
}
editor.style.width = `${width - 1}px`;
editor.style.height = `${height - 1}px`;
// Position the cell outline and clip it // Position the cell outline and clip it
cellOutline.style.left = `${x - padding - 1}px`; cellOutline.style.left = `${x - padding - 1}px`;
cellOutline.style.top = `${y - padding - 1}px`; cellOutline.style.top = `${y - padding - 1}px`;
@@ -1151,16 +1181,9 @@ export default class WorksheetCanvas {
// New properties // New properties
cellOutline.style.width = `${width}px`; cellOutline.style.width = `${width}px`;
cellOutline.style.height = `${height}px`; cellOutline.style.height = `${height}px`;
if (cellEditing) {
cellOutline.style.fontWeight = style.font.b ? "bold" : "normal";
cellOutline.style.fontStyle = style.font.i ? "italic" : "normal";
// cellOutline.style.backgroundColor = style.fill.fg_color;
// TODO: Should we add the same color as the text?
// Only if it is not a formula?
// cellOutline.style.color = style.font.color;
} else {
cellOutline.style.background = "none"; cellOutline.style.background = "none";
}
// border is 2px so line-height must be height - 4 // border is 2px so line-height must be height - 4
cellOutline.style.lineHeight = `${height - 4}px`; cellOutline.style.lineHeight = `${height - 4}px`;
let { let {
@@ -1236,11 +1259,6 @@ export default class WorksheetCanvas {
} }
} }
// draw the handle
if (cellEditing !== null) {
cellOutlineHandle.style.visibility = "hidden";
return;
}
const handleBBox = cellOutlineHandle.getBoundingClientRect(); const handleBBox = cellOutlineHandle.getBoundingClientRect();
const handleWidth = handleBBox.width; const handleWidth = handleBBox.width;
const handleHeight = handleBBox.height; const handleHeight = handleBBox.height;
@@ -1442,6 +1460,7 @@ export default class WorksheetCanvas {
context.fillRect(0, 0, headerColumnWidth, headerRowHeight); context.fillRect(0, 0, headerColumnWidth, headerRowHeight);
this.drawCellOutline(); this.drawCellOutline();
this.drawCellEditor();
this.drawExtendToArea(); this.drawExtendToArea();
this.drawActiveRanges(topLeftCell, bottomRightCell); this.drawActiveRanges(topLeftCell, bottomRightCell);
} }

View File

@@ -1,5 +1,6 @@
// This is the cell editor for IronCalc // 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. // 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 // 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. // but in HTML so we can have different colors.
// Some keystrokes have different behaviour than a raw HTML text area. // Some keystrokes have different behaviour than a raw HTML text area.
@@ -63,10 +64,6 @@ const commonCSS: CSSProperties = {
const caretColor = "#FF8899"; const caretColor = "#FF8899";
interface EditorOptions { interface EditorOptions {
minimalWidth: number | string;
minimalHeight: number | string;
display: boolean;
expand: boolean;
originalText: string; originalText: string;
onEditEnd: () => void; onEditEnd: () => void;
onTextUpdated: () => void; onTextUpdated: () => void;
@@ -76,21 +73,9 @@ interface EditorOptions {
} }
const Editor = (options: EditorOptions) => { const Editor = (options: EditorOptions) => {
const { const { model, onEditEnd, onTextUpdated, originalText, workbookState, type } =
display, options;
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 [text, setText] = useState(originalText);
const [styledFormula, setStyledFormula] = useState( const [styledFormula, setStyledFormula] = useState(
getFormulaHTML(model, text, "").html, getFormulaHTML(model, text, "").html,
@@ -120,10 +105,27 @@ const Editor = (options: EditorOptions) => {
}); });
useEffect(() => { useEffect(() => {
if (display) { if (text.length === 0) {
textareaRef.current?.focus(); // noop
} }
}, [display]); }, [text]);
useEffect(() => {
const cell = workbookState.getEditingCell();
if (text.length === 0) {
// noop, just to keep the linter happy
}
if (!cell) {
return;
}
const { editorWidth, editorHeight } = cell;
if (formulaRef.current) {
const scrollWidth = formulaRef.current.scrollWidth;
if (scrollWidth > editorWidth - 5) {
cell.editorWidth = scrollWidth + 10;
}
}
}, [text, workbookState]);
const onChange = useCallback(() => { const onChange = useCallback(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
@@ -137,7 +139,7 @@ const Editor = (options: EditorOptions) => {
cell.cursorStart = textarea.selectionStart; cell.cursorStart = textarea.selectionStart;
cell.cursorEnd = textarea.selectionEnd; cell.cursorEnd = textarea.selectionEnd;
const styledFormula = getFormulaHTML(model, cell.text, ""); const styledFormula = getFormulaHTML(model, cell.text, "");
if (value === "") { if (value === "" && type === "cell") {
// When we delete the content of a cell we jump to accept mode // When we delete the content of a cell we jump to accept mode
cell.mode = "accept"; cell.mode = "accept";
} }
@@ -152,9 +154,15 @@ const Editor = (options: EditorOptions) => {
// Should we stop propagations? // Should we stop propagations?
// event.stopPropagation(); // event.stopPropagation();
// event.preventDefault(); // event.preventDefault();
}, [workbookState, model, onTextUpdated]); }, [workbookState, model, onTextUpdated, type]);
const onBlur = useCallback(() => { 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) { if (textareaRef.current) {
textareaRef.current.value = ""; textareaRef.current.value = "";
setStyledFormula(getFormulaHTML(model, "", "").html); setStyledFormula(getFormulaHTML(model, "", "").html);
@@ -163,7 +171,6 @@ const Editor = (options: EditorOptions) => {
// This happens if the blur hasn't been taken care before by // This happens if the blur hasn't been taken care before by
// onclick or onpointerdown events // onclick or onpointerdown events
// If we are editing a cell finish that // If we are editing a cell finish that
const cell = workbookState.getEditingCell();
if (cell) { if (cell) {
model.setUserInput( model.setUserInput(
cell.sheet, cell.sheet,
@@ -174,19 +181,25 @@ const Editor = (options: EditorOptions) => {
workbookState.clearEditingCell(); workbookState.clearEditingCell();
} }
onEditEnd(); onEditEnd();
}, [model, workbookState, onEditEnd]); }, [model, workbookState, onEditEnd, type]);
const isCellEditing = workbookState.getEditingCell() !== null; const cell = workbookState.getEditingCell();
const showEditor = // If we are the focus, the get it
(isCellEditing && display) || type === "formula-bar" ? "block" : "none"; if (cell) {
if (type === cell.focus) {
textareaRef.current?.focus();
}
}
const showEditor = cell !== null || type === "formula-bar" ? "block" : "none";
return ( return (
<div <div
style={{ style={{
position: "relative", position: "relative",
width, width: "100%",
height, height: "100%",
overflow: "hidden", overflow: "hidden",
display: showEditor, display: showEditor,
background: "#FFF", background: "#FFF",
@@ -198,10 +211,17 @@ const Editor = (options: EditorOptions) => {
...commonCSS, ...commonCSS,
textAlign: "left", textAlign: "left",
pointerEvents: "none", pointerEvents: "none",
height, height: "100%",
}} }}
> >
<div ref={formulaRef}>{styledFormula}</div> <div
style={{
display: "inline-block",
}}
ref={formulaRef}
>
{styledFormula}
</div>
</div> </div>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -214,25 +234,34 @@ const Editor = (options: EditorOptions) => {
outline: "none", outline: "none",
resize: "none", resize: "none",
border: "none", border: "none",
height, height: "100%",
overflow: "hidden", overflow: "hidden",
alignContent: "baseline",
}} }}
defaultValue={text} defaultValue={text}
spellCheck="false" spellCheck="false"
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
onClick={(event) => { onPointerDown={(event) => {
// Prevents this from bubbling up and focusing on the spreadsheet // We are either clicking in the same cell we are editing,
if (isCellEditing && type === "cell") { // 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(); const cell = workbookState.getEditingCell();
if (cell) { if (cell) {
// We make sure the mode is edit
cell.mode = "edit"; cell.mode = "edit";
cell.focus = type;
workbookState.setEditingCell(cell); workbookState.setEditingCell(cell);
}
event.stopPropagation(); 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> </div>
); );

View File

@@ -2,6 +2,10 @@ import type { Model } from "@ironcalc/wasm";
import { Button, styled } from "@mui/material"; import { Button, styled } from "@mui/material";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { Fx } from "../icons"; import { Fx } from "../icons";
import {
COLUMN_WIDTH_SCALE,
ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants";
import { FORMULA_BAR_HEIGH } from "./constants"; import { FORMULA_BAR_HEIGH } from "./constants";
import Editor from "./editor/editor"; import Editor from "./editor/editor";
import type { WorkbookState } from "./workbookState"; import type { WorkbookState } from "./workbookState";
@@ -42,6 +46,10 @@ function FormulaBar(properties: FormulaBarProps) {
<EditorWrapper <EditorWrapper
onClick={(event) => { onClick={(event) => {
const [sheet, row, column] = model.getSelectedCell(); const [sheet, row, column] = model.getSelectedCell();
const editorWidth =
model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE;
const editorHeight =
model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE;
workbookState.setEditingCell({ workbookState.setEditingCell({
sheet, sheet,
row, row,
@@ -52,17 +60,15 @@ function FormulaBar(properties: FormulaBarProps) {
cursorEnd: formulaValue.length, cursorEnd: formulaValue.length,
focus: "formula-bar", focus: "formula-bar",
activeRanges: [], activeRanges: [],
mode: "accept", mode: "edit",
editorWidth,
editorHeight,
}); });
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
}} }}
> >
<Editor <Editor
minimalWidth={"100%"}
minimalHeight={"100%"}
display={true}
expand={false}
originalText={formulaValue} originalText={formulaValue}
model={model} model={model}
workbookState={workbookState} workbookState={workbookState}

View File

@@ -1,7 +1,11 @@
import type { BorderOptions, Model, WorksheetProperties } from "@ironcalc/wasm"; import type { BorderOptions, Model, WorksheetProperties } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { LAST_COLUMN } from "./WorksheetCanvas/constants"; import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants";
import FormulaBar from "./formulabar"; import FormulaBar from "./formulabar";
import Navigation from "./navigation/navigation"; import Navigation from "./navigation/navigation";
import Toolbar from "./toolbar"; import Toolbar from "./toolbar";
@@ -144,6 +148,9 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}, },
onEditKeyPressStart: (initText: string): void => { onEditKeyPressStart: (initText: string): void => {
const { sheet, row, column } = model.getSelectedView(); const { sheet, row, column } = model.getSelectedView();
const editorWidth =
model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE;
const editorHeight = model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE;
workbookState.setEditingCell({ workbookState.setEditingCell({
sheet, sheet,
row, row,
@@ -155,6 +162,8 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
referencedRange: null, referencedRange: null,
activeRanges: [], activeRanges: [],
mode: "accept", mode: "accept",
editorWidth,
editorHeight,
}); });
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}, },
@@ -162,6 +171,9 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
// User presses F2, we start editing at the edn of the text // User presses F2, we start editing at the edn of the text
const { sheet, row, column } = model.getSelectedView(); const { sheet, row, column } = model.getSelectedView();
const text = model.getCellContent(sheet, row, column); const text = model.getCellContent(sheet, row, column);
const editorWidth =
model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE;
const editorHeight = model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE;
workbookState.setEditingCell({ workbookState.setEditingCell({
sheet, sheet,
row, row,
@@ -173,6 +185,8 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
focus: "cell", focus: "cell",
activeRanges: [], activeRanges: [],
mode: "edit", mode: "edit",
editorWidth,
editorHeight,
}); });
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}, },

View File

@@ -72,6 +72,8 @@ export interface EditingCell {
focus: Focus; focus: Focus;
activeRanges: ActiveRange[]; activeRanges: ActiveRange[];
mode: EditorMode; mode: EditorMode;
editorWidth: number;
editorHeight: number;
} }
// Those are styles that are copied // Those are styles that are copied

View File

@@ -2,6 +2,8 @@ import type { Model } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { import {
COLUMN_WIDTH_SCALE,
ROW_HEIGH_SCALE,
outlineBackgroundColor, outlineBackgroundColor,
outlineColor, outlineColor,
} from "./WorksheetCanvas/constants"; } from "./WorksheetCanvas/constants";
@@ -329,6 +331,9 @@ function Worksheet(props: {
// Starts editing cell // Starts editing cell
const { sheet, row, column } = model.getSelectedView(); const { sheet, row, column } = model.getSelectedView();
const text = model.getCellContent(sheet, row, column) || ""; const text = model.getCellContent(sheet, row, column) || "";
const editorWidth =
model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE;
const editorHeight = model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE;
workbookState.setEditingCell({ workbookState.setEditingCell({
sheet, sheet,
row, row,
@@ -340,20 +345,19 @@ function Worksheet(props: {
referencedRange: null, referencedRange: null,
activeRanges: [], activeRanges: [],
mode: "accept", mode: "accept",
editorWidth,
editorHeight,
}); });
setOriginalText(text); setOriginalText(text);
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
props.refresh();
}} }}
> >
<SheetCanvas ref={canvasElement} /> <SheetCanvas ref={canvasElement} />
<CellOutline ref={cellOutline} /> <CellOutline ref={cellOutline} />
<EditorWrapper ref={editorElement}> <EditorWrapper ref={editorElement}>
<Editor <Editor
minimalWidth={"100%"}
minimalHeight={"100%"}
display={workbookState.getEditingCell()?.focus === "cell"}
expand={true}
originalText={workbookState.getEditingText() || originalText} originalText={workbookState.getEditingText() || originalText}
onEditEnd={(): void => { onEditEnd={(): void => {
props.refresh(); props.refresh();
@@ -492,7 +496,6 @@ const CellOutlineHandle = styled("div")`
height: 5px; height: 5px;
background: ${outlineColor}; background: ${outlineColor};
cursor: crosshair; cursor: crosshair;
// border: 1px solid white;
border-radius: 1px; border-radius: 1px;
`; `;
@@ -517,6 +520,7 @@ const EditorWrapper = styled("div")`
min-width: 1px; min-width: 1px;
} }
font-family: monospace; font-family: monospace;
border: 2px solid ${outlineColor};
`; `;
export default Worksheet; export default Worksheet;