UPDATE: Adds cell and formula editing

This commit is contained in:
Nicolás Hatcher
2024-08-24 06:05:03 +02:00
parent bf9a1ed9f4
commit f53b39b220
11 changed files with 759 additions and 92 deletions

View File

@@ -1,6 +1,7 @@
#root {
position: absolute;
inset: 10px;
inset: 0px;
margin: 10px;
border: 1px solid #aaa;
border-radius: 4px;
}

View File

@@ -11,6 +11,7 @@ function App() {
const [workbookState, setWorkbookState] = useState<WorkbookState | null>(
null,
);
useEffect(() => {
async function start() {
await init();
@@ -42,6 +43,7 @@ function App() {
// We could use context for model, but the problem is that it should initialized to null.
// Passing the property down makes sure it is always defined.
return <Workbook model={model} workbookState={workbookState} />;
}

View File

@@ -30,6 +30,7 @@ export interface CanvasSettings {
columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement;
columnHeaders: HTMLDivElement;
editor: HTMLDivElement;
};
onColumnWidthChanges: (sheet: number, column: number, width: number) => void;
onRowHeightChanges: (sheet: number, row: number, height: number) => void;
@@ -48,6 +49,23 @@ export const defaultCellFontFamily = fonts.regular;
export const headerFontFamily = fonts.regular;
export const frozenSeparatorWidth = 3;
// Get a 10% transparency of an hex color
function hexToRGBA10Percent(colorHex: string): string {
// Remove the leading hash (#) if present
const hex = colorHex.replace(/^#/, "");
// Parse the hex color
const red = Number.parseInt(hex.substring(0, 2), 16);
const green = Number.parseInt(hex.substring(2, 4), 16);
const blue = Number.parseInt(hex.substring(4, 6), 16);
// Set the alpha (opacity) to 0.1 (10%)
const alpha = 0.1;
// Return the RGBA color string
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
export default class WorksheetCanvas {
sheetWidth: number;
@@ -61,6 +79,8 @@ export default class WorksheetCanvas {
canvas: HTMLCanvasElement;
editor: HTMLDivElement;
areaOutline: HTMLDivElement;
cellOutline: HTMLDivElement;
@@ -92,6 +112,7 @@ export default class WorksheetCanvas {
this.height = options.height;
this.ctx = this.setContext();
this.workbookState = options.workbookState;
this.editor = options.elements.editor;
this.cellOutline = options.elements.cellOutline;
this.cellOutlineHandle = options.elements.cellOutlineHandle;
@@ -1092,7 +1113,7 @@ export default class WorksheetCanvas {
this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding;
const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding;
const { cellOutline, areaOutline, cellOutlineHandle } = this;
const { cellOutline, editor, areaOutline, cellOutlineHandle } = this;
const cellEditing = null;
cellOutline.style.visibility = "visible";
@@ -1105,6 +1126,11 @@ export default class WorksheetCanvas {
cellOutlineHandle.style.visibility = "hidden";
}
editor.style.left = `${x + 3}px`;
editor.style.top = `${y + 3}px`;
editor.style.width = `${width - 1}px`;
editor.style.height = `${height - 1}px`;
// Position the cell outline and clip it
cellOutline.style.left = `${x - padding}px`;
cellOutline.style.top = `${y - padding}px`;
@@ -1214,6 +1240,52 @@ export default class WorksheetCanvas {
cellOutlineHandle.style.top = `${handleY - handleHeight / 2}px`;
}
private drawActiveRanges(topLeftCell: Cell, bottomRightCell: Cell): void {
const activeRanges = this.workbookState.getActiveRanges();
const activeRangesCount = activeRanges.length;
const ctx = this.ctx;
ctx.setLineDash([2, 2]);
for (let rangeIndex = 0; rangeIndex < activeRangesCount; rangeIndex += 1) {
const range = activeRanges[rangeIndex];
const allowedOffset = 1; // to make borders look nicer
const minRow = topLeftCell.row - allowedOffset;
const maxRow = bottomRightCell.row + allowedOffset;
const minColumn = topLeftCell.column - allowedOffset;
const maxColumn = bottomRightCell.column + allowedOffset;
if (
minRow <= range.rowEnd &&
range.rowStart <= maxRow &&
minColumn <= range.columnEnd &&
range.columnStart < maxColumn
) {
// Range in the viewport.
const displayRange: typeof range = {
...range,
rowStart: Math.max(minRow, range.rowStart),
rowEnd: Math.min(maxRow, range.rowEnd),
columnStart: Math.max(minColumn, range.columnStart),
columnEnd: Math.min(maxColumn, range.columnEnd),
};
const [xStart, yStart] = this.getCoordinatesByCell(
displayRange.rowStart,
displayRange.columnStart,
);
const [xEnd, yEnd] = this.getCoordinatesByCell(
displayRange.rowEnd + 1,
displayRange.columnEnd + 1,
);
ctx.strokeStyle = range.color;
ctx.lineWidth = 1;
ctx.strokeRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
ctx.fillStyle = hexToRGBA10Percent(range.color);
ctx.fillRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
}
}
ctx.setLineDash([]);
}
renderSheet(): void {
console.time("renderSheet");
this._renderSheet();
@@ -1352,5 +1424,6 @@ export default class WorksheetCanvas {
this.drawCellOutline();
this.drawExtendToArea();
this.drawActiveRanges(topLeftCell, bottomRightCell);
}
}

View File

@@ -0,0 +1,243 @@
// 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.
// 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
import type { Model } from "@ironcalc/wasm";
import {
type CSSProperties,
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { WorkbookState } from "../workbookState";
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 {
minimalWidth: number | string;
minimalHeight: number | string;
display: boolean;
expand: boolean;
originalText: string;
onEditEnd: () => void;
onTextUpdated: () => void;
model: Model;
workbookState: WorkbookState;
type: "cell" | "formula-bar";
}
const Editor = (options: EditorOptions) => {
const {
display,
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 [styledFormula, setStyledFormula] = useState(
getFormulaHTML(model, text).html,
);
const formulaRef = useRef<HTMLDivElement>(null);
const maskRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setText(originalText);
setStyledFormula(getFormulaHTML(model, originalText).html);
if (textareaRef.current) {
textareaRef.current.value = originalText;
}
}, [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) {
workbookState.clearEditingCell();
model.setUserInput(cell.sheet, cell.row, cell.column, cell.text);
const sign = shiftKey ? -1 : 1;
model.setSelectedCell(cell.row + sign, cell.column);
}
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);
const sign = shiftKey ? -1 : 1;
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
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 value = textarea.value;
const styledFormula = getFormulaHTML(model, value);
const cell = workbookState.getEditingCell();
if (cell) {
cell.text = value;
workbookState.setEditingCell(cell);
workbookState.setActiveRanges(styledFormula.activeRanges);
setStyledFormula(styledFormula.html);
onTextUpdated();
}
}, 0);
}
}
},
[model, text, onEditEnd, onTextUpdated, workbookState],
);
useEffect(() => {
if (display) {
textareaRef.current?.focus();
}
}, [display]);
const onChange = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.value = "";
setStyledFormula(getFormulaHTML(model, "").html);
}
// This happens if the blur hasn't been taken care before by
// onclick or onpointerdown events
// If we are editing a cell finish that
const cell = workbookState.getEditingCell();
if (cell) {
workbookState.clearEditingCell();
model.setUserInput(cell.sheet, cell.row, cell.column, cell.text);
}
onEditEnd();
}, [model, workbookState, onEditEnd]);
const isCellEditing = workbookState.getEditingCell() !== null;
const showEditor =
isCellEditing && (display || type === "formula-bar") ? "block" : "none";
return (
<div
style={{
position: "relative",
width,
height,
overflow: "hidden",
display: showEditor,
background: "#FFF",
}}
>
<div
ref={maskRef}
style={{
...commonCSS,
textAlign: "left",
pointerEvents: "none",
height,
}}
>
<div ref={formulaRef}>{styledFormula}</div>
</div>
<textarea
ref={textareaRef}
rows={1}
style={{
...commonCSS,
color: "transparent",
backgroundColor: "transparent",
caretColor,
outline: "none",
resize: "none",
border: "none",
height,
display: display ? "block" : "none",
overflow: "hidden",
}}
defaultValue={text}
spellCheck="false"
onKeyDown={onKeyDown}
onBlur={onChange}
/>
</div>
);
};
export default Editor;

View File

@@ -0,0 +1,189 @@
import {
type Model,
type Range,
type Reference,
type TokenType,
getTokens,
} from "@ironcalc/wasm";
import type { ActiveRange } from "../workbookState";
export function tokenIsReferenceType(token: TokenType): token is Reference {
return typeof token === "object" && "Reference" in token;
}
export function tokenIsRangeType(token: TokenType): token is Range {
return typeof token === "object" && "Range" in token;
}
function isInReferenceMode(text: string, cursor: number): boolean {
// FIXME
// This is a gross oversimplification
// Returns true if both are true:
// 1. Cursor is at the end
// 2. Last char is one of [',', '(', '+', '*', '-', '/', '<', '>', '=', '&']
// This has many false positives like '="1+' and also likely some false negatives
// The right way of doing this is to have a partial parse of the formula tree
// and check if the next token could be a reference
if (!text.startsWith("=")) {
return false;
}
if (text === "=") {
return true;
}
const l = text.length;
const chars = [",", "(", "+", "*", "-", "/", "<", ">", "=", "&"];
if (cursor === l && chars.includes(text[l - 1])) {
return true;
}
return false;
}
// IronCalc Color Palette
export function getColor(index: number, alpha = 1): string {
const colors = [
{
name: "Cyan",
rgba: [89, 185, 188, 1],
hex: "#59B9BC",
},
{
name: "Flamingo",
rgba: [236, 87, 83, 1],
hex: "#EC5753",
},
{
hex: "#3358B7",
rgba: [51, 88, 183, 1],
name: "Blue",
},
{
hex: "#F8CD3C",
rgba: [248, 205, 60, 1],
name: "Yellow",
},
{
hex: "#3BB68A",
rgba: [59, 182, 138, 1],
name: "Emerald",
},
{
hex: "#523E93",
rgba: [82, 62, 147, 1],
name: "Violet",
},
{
hex: "#A23C52",
rgba: [162, 60, 82, 1],
name: "Burgundy",
},
{
hex: "#8CB354",
rgba: [162, 60, 82, 1],
name: "Wasabi",
},
{
hex: "#D03627",
rgba: [208, 54, 39, 1],
name: "Red",
},
{
hex: "#1B717E",
rgba: [27, 113, 126, 1],
name: "Teal",
},
];
if (alpha === 1) {
return colors[index % 10].hex;
}
const { rgba } = colors[index % 10];
return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${alpha})`;
}
function getFormulaHTML(
model: Model,
text: string,
): { html: JSX.Element[]; activeRanges: ActiveRange[] } {
let html: JSX.Element[] = [];
const activeRanges: ActiveRange[] = [];
let colorCount = 0;
if (text.startsWith("=")) {
const formula = text.slice(1);
const tokens = getTokens(formula);
const tokenCount = tokens.length;
const usedColors: Record<string, string> = {};
const sheet = model.getSelectedSheet();
const sheetList = model.getWorksheetsProperties().map((s) => s.name);
for (let index = 0; index < tokenCount; index += 1) {
const { token, start, end } = tokens[index];
if (tokenIsReferenceType(token)) {
const { sheet: refSheet, row, column } = token.Reference;
const sheetIndex = refSheet ? sheetList.indexOf(refSheet) : sheet;
const key = `${sheetIndex}-${row}-${column}`;
let color = usedColors[key];
if (!color) {
color = getColor(colorCount);
usedColors[key] = color;
colorCount += 1;
}
html.push(
<span key={index} style={{ color }}>
{formula.slice(start, end)}
</span>,
);
activeRanges.push({
sheet: sheetIndex,
rowStart: row,
columnStart: column,
rowEnd: row,
columnEnd: column,
color,
});
} else if (tokenIsRangeType(token)) {
let {
sheet: refSheet,
left: { row: rowStart, column: columnStart },
right: { row: rowEnd, column: columnEnd },
} = token.Range;
const sheetIndex = refSheet ? sheetList.indexOf(refSheet) : sheet;
const key = `${sheetIndex}-${rowStart}-${columnStart}:${rowEnd}-${columnEnd}`;
let color = usedColors[key];
if (!color) {
color = getColor(colorCount);
usedColors[key] = color;
colorCount += 1;
}
if (rowStart > rowEnd) {
[rowStart, rowEnd] = [rowEnd, rowStart];
}
if (columnStart > columnEnd) {
[columnStart, columnEnd] = [columnEnd, columnStart];
}
html.push(
<span key={index} style={{ color }}>
{formula.slice(start, end)}
</span>,
);
colorCount += 1;
activeRanges.push({
sheet: sheetIndex,
rowStart,
columnStart,
rowEnd,
columnEnd,
color,
});
} else {
html.push(<span key={index}>{formula.slice(start, end)}</span>);
}
}
html = [<span key="equals">=</span>].concat(html);
} else {
html = [<span key="single">{text}</span>];
}
return { html, activeRanges };
}
export default getFormulaHTML;

View File

@@ -1,50 +0,0 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface FormulaDialogProps {
isOpen: boolean;
close: () => void;
onFormulaChanged: (name: string) => void;
defaultFormula: string;
}
export const FormulaDialog = (properties: FormulaDialogProps) => {
const { t } = useTranslation();
const [formula, setFormula] = useState(properties.defaultFormula);
return (
<Dialog open={properties.isOpen} onClose={properties.close}>
<DialogTitle>{t("formula_input.title")}</DialogTitle>
<DialogContent dividers>
<TextField
defaultValue={formula}
label={t("formula_input.label")}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
}}
onChange={(event) => {
setFormula(event.target.value);
}}
spellCheck="false"
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
properties.onFormulaChanged(formula);
}}
>
{t("formula_input.update")}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,28 +1,39 @@
import type { Model } from "@ironcalc/wasm";
import { Button, styled } from "@mui/material";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { Fx } from "../icons";
import { FormulaDialog } from "./formulaDialog";
import Editor from "./editor/editor";
import type { WorkbookState } from "./workbookState";
type FormulaBarProps = {
cellAddress: string;
formulaValue: string;
onChange: (value: string) => void;
model: Model;
workbookState: WorkbookState;
onChange: () => void;
onTextUpdated: () => void;
};
const formulaBarHeight = 30;
const headerColumnWidth = 30;
const headerColumnWidth = 35;
function FormulaBar(properties: FormulaBarProps) {
const [formulaDialogOpen, setFormulaDialogOpen] = useState(false);
const handleCloseFormulaDialog = () => {
setFormulaDialogOpen(false);
};
const {
cellAddress,
formulaValue,
model,
onChange,
onTextUpdated,
workbookState,
} = properties;
const [display, setDisplay] = useState(false);
return (
<Container>
<AddressContainer>
<CellBarAddress>{properties.cellAddress}</CellBarAddress>
<CellBarAddress>{cellAddress}</CellBarAddress>
<StyledButton>
<ChevronDown />
</StyledButton>
@@ -32,23 +43,40 @@ function FormulaBar(properties: FormulaBarProps) {
<FormulaSymbolButton>
<Fx />
</FormulaSymbolButton>
<Editor
onClick={() => {
setFormulaDialogOpen(true);
<EditorWrapper
onClick={(event) => {
const [sheet, row, column] = model.getSelectedCell();
workbookState.setEditingCell({
sheet,
row,
column,
text: formulaValue,
cursor: 0,
focus: "formula-bar",
activeRanges: [],
});
setDisplay(true);
event.stopPropagation();
event.preventDefault();
}}
>
{properties.formulaValue}
</Editor>
<Editor
minimalWidth={"100%"}
minimalHeight={"100%"}
display={display}
expand={false}
originalText={formulaValue}
model={model}
workbookState={workbookState}
onEditEnd={() => {
setDisplay(false);
onChange();
}}
onTextUpdated={onTextUpdated}
type="formula-bar"
/>
</EditorWrapper>
</FormulaContainer>
<FormulaDialog
isOpen={formulaDialogOpen}
close={handleCloseFormulaDialog}
defaultFormula={properties.formulaValue}
onFormulaChanged={(newName) => {
properties.onChange(newName);
setFormulaDialogOpen(false);
}}
/>
</Container>
);
}
@@ -113,7 +141,7 @@ const CellBarAddress = styled("div")`
text-align: "center";
`;
const Editor = styled("div")`
const EditorWrapper = styled("div")`
position: relative;
width: 100%;
padding: 0px;
@@ -127,6 +155,7 @@ const Editor = styled("div")`
span {
min-width: 1px;
}
font-family: monospace;
`;
export default FormulaBar;

View File

@@ -143,11 +143,31 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
setRedrawId((id) => id + 1);
},
onEditKeyPressStart: (initText: string): void => {
console.log(initText);
throw new Error("Function not implemented.");
const { sheet, row, column } = model.getSelectedView();
workbookState.setEditingCell({
sheet,
row,
column,
text: initText,
cursor: 0,
focus: "cell",
activeRanges: [],
});
setRedrawId((id) => id + 1);
},
onCellEditStart: (): void => {
throw new Error("Function not implemented.");
const { sheet, row, column } = model.getSelectedView();
const text = model.getCellContent(sheet, row, column);
workbookState.setEditingCell({
sheet,
row,
column,
text,
cursor: text.length,
focus: "cell",
activeRanges: [],
});
setRedrawId((id) => id + 1);
},
onBold: () => {
const { sheet, row, column } = model.getSelectedView();
@@ -237,7 +257,9 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
if (!rootRef.current) {
return;
}
rootRef.current.focus();
if (!workbookState.getEditingCell()) {
rootRef.current.focus();
}
});
const {
@@ -251,12 +273,25 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
{ rowStart, rowEnd, columnStart, columnEnd },
{ row, column },
);
const formulaValue = model.getCellContent(sheet, row, column);
const formulaValue = (() => {
const cell = workbookState.getEditingCell();
if (cell) {
return cell.text;
}
return model.getCellContent(sheet, row, column);
})();
const style = model.getCellStyle(sheet, row, column);
return (
<Container ref={rootRef} onKeyDown={onKeyDown} tabIndex={0}>
<Container
ref={rootRef}
onKeyDown={onKeyDown}
tabIndex={0}
onClick={() => {
rootRef.current?.focus();
}}
>
<Toolbar
canUndo={model.canUndo()}
canRedo={model.canRedo()}
@@ -313,10 +348,15 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
<FormulaBar
cellAddress={cellAddress}
formulaValue={formulaValue}
onChange={(value) => {
model.setUserInput(sheet, row, column, value);
onChange={() => {
setRedrawId((id) => id + 1);
rootRef.current?.focus();
}}
onTextUpdated={() => {
setRedrawId((id) => id + 1);
}}
model={model}
workbookState={workbookState}
/>
<Worksheet
model={model}
@@ -325,6 +365,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
setRedrawId((id) => id + 1);
}}
/>
<Navigation
sheets={info}
selectedIndex={model.getSelectedSheet()}

View File

@@ -1,3 +1,18 @@
// This are properties of the workbook that are not permanently stored
// They only happen at 'runtime' while the workbook is being used:
//
// * What are we editing
// * Are we copying styles?
// * Are we extending a cell? (by pulling the cell outline handle down, for instance)
//
// Editing the cell is the most complex operation.
//
// * What cell are we editing?
// * Are we doing that from the cell editor or the formula editor?
// * What is the text content of the cell right now
// * The active ranges can technically be computed from the text.
// Those are the ranges or cells that appear in the formula
import type { CellStyle } from "@ironcalc/wasm";
export enum AreaType {
@@ -15,15 +30,44 @@ export interface Area {
columnEnd: number;
}
// Active ranges are ranges in the sheet that are highlighted when editing a formula
export interface ActiveRange {
sheet: number;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
color: string;
}
type Focus = "cell" | "formula-bar";
// The cell that we are editing
export interface EditingCell {
sheet: number;
row: number;
column: number;
// raw text in the editor
text: string;
// position of the cursor
cursor: number;
focus: Focus;
activeRanges: ActiveRange[];
}
// Those are styles that are copied
type AreaStyles = CellStyle[][];
export class WorkbookState {
private extendToArea: Area | null;
private copyStyles: AreaStyles | null;
private cell: EditingCell | null;
constructor() {
// the extendTo area is the area we are covering
this.extendToArea = null;
this.copyStyles = null;
this.cell = null;
}
getExtendToArea(): Area | null {
@@ -45,4 +89,41 @@ export class WorkbookState {
getCopyStyles(): AreaStyles | null {
return this.copyStyles;
}
setActiveRanges(activeRanges: ActiveRange[]) {
if (!this.cell) {
return;
}
this.cell.activeRanges = activeRanges;
}
getActiveRanges(): ActiveRange[] {
return this.cell?.activeRanges || [];
}
getEditingCell(): EditingCell | null {
return this.cell;
}
setEditingCell(cell: EditingCell) {
this.cell = cell;
}
clearEditingCell() {
this.cell = null;
}
isCellEditorActive(): boolean {
if (this.cell) {
return this.cell.focus === "cell";
}
return false;
}
isFormulaEditorActive(): boolean {
if (this.cell) {
return this.cell.focus === "formula-bar";
}
return false;
}
}

View File

@@ -6,6 +6,7 @@ import {
outlineColor,
} from "./WorksheetCanvas/constants";
import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
import Editor from "./editor/editor";
import type { Cell } from "./types";
import usePointer from "./usePointer";
import { AreaType, type WorkbookState } from "./workbookState";
@@ -32,7 +33,8 @@ function Worksheet(props: {
const worksheetElement = useRef<HTMLDivElement>(null);
const scrollElement = useRef<HTMLDivElement>(null);
// const rootElement = useRef<HTMLDivElement>(null);
const editorElement = useRef<HTMLDivElement>(null);
const spacerElement = useRef<HTMLDivElement>(null);
const cellOutline = useRef<HTMLDivElement>(null);
const areaOutline = useRef<HTMLDivElement>(null);
@@ -45,8 +47,12 @@ function Worksheet(props: {
const ignoreScrollEventRef = useRef(false);
const [display, setDisplay] = useState(false);
const [originalText, setOriginalText] = useState("");
const { model, workbookState, refresh } = props;
const [clientWidth, clientHeight] = useWindowSize();
useEffect(() => {
const canvasRef = canvasElement.current;
const columnGuideRef = columnResizeGuide.current;
@@ -58,6 +64,7 @@ function Worksheet(props: {
const handle = cellOutlineHandle.current;
const area = areaOutline.current;
const extendTo = extendToOutline.current;
const editor = editorElement.current;
if (
!canvasRef ||
@@ -69,7 +76,8 @@ function Worksheet(props: {
!handle ||
!area ||
!extendTo ||
!scrollElement.current
!scrollElement.current ||
!editor
)
return;
model.setWindowWidth(clientWidth - 37);
@@ -88,6 +96,7 @@ function Worksheet(props: {
cellOutlineHandle: handle,
areaOutline: area,
extendToOutline: extendTo,
editor: editor,
},
onColumnWidthChanges(sheet, column, width) {
model.setColumnWidth(sheet, column, width);
@@ -100,7 +109,7 @@ function Worksheet(props: {
});
const scrollX = model.getScrollX();
const scrollY = model.getScrollY();
const [sheetWidth, sheetHeight] = [scrollX + 100_000, scrollY + 500_000]; //canvas.getSheetDimensions();
const [sheetWidth, sheetHeight] = [scrollX + 100_000, scrollY + 500_000];
if (spacerElement.current) {
spacerElement.current.style.height = `${sheetHeight}px`;
spacerElement.current.style.width = `${sheetWidth}px`;
@@ -127,10 +136,6 @@ function Worksheet(props: {
worksheetCanvas.current = canvas;
});
const sheetNames = model
.getWorksheetsProperties()
.map((s: { name: string }) => s.name);
const {
onPointerMove,
onPointerDown,
@@ -311,20 +316,55 @@ function Worksheet(props: {
className="sheet-container"
ref={worksheetElement}
onPointerDown={(event) => {
// if we are editing a cell finish that
const cell = workbookState.getEditingCell();
if (cell) {
workbookState.clearEditingCell();
model.setUserInput(cell.sheet, cell.row, cell.column, cell.text);
}
onPointerDown(event);
}}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={(event) => {
const { sheet, row, column } = model.getSelectedView();
const _text = model.getCellContent(sheet, row, column) || "";
// TODO
const text = model.getCellContent(sheet, row, column) || "";
workbookState.setEditingCell({
sheet,
row,
column,
text,
cursor: 0,
focus: "cell",
activeRanges: [],
});
setDisplay(true);
setOriginalText(text);
event.stopPropagation();
event.preventDefault();
}}
>
<SheetCanvas ref={canvasElement} />
<CellOutline ref={cellOutline} />
<EditorWrapper ref={editorElement}>
<Editor
minimalWidth={"100%"}
minimalHeight={"100%"}
display={workbookState.getEditingCell()?.focus === "cell"}
expand={true}
originalText={workbookState.getEditingCell()?.text || originalText}
onEditEnd={(): void => {
setDisplay(false);
props.refresh();
}}
onTextUpdated={(): void => {
props.refresh();
}}
model={model}
workbookState={workbookState}
type={"cell"}
/>
</EditorWrapper>
<AreaOutline ref={areaOutline} />
<ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle
@@ -461,4 +501,21 @@ const ExtendToOutline = styled("div")`
border-radius: 3px;
`;
const EditorWrapper = styled("div")`
position: absolute;
width: 100%;
padding: 0px;
border-width: 0px;
outline: none;
resize: none;
white-space: pre-wrap;
vertical-align: bottom;
overflow: hidden;
text-align: left;
span {
min-width: 1px;
}
font-family: monospace;
`;
export default Worksheet;

View File

@@ -1,4 +1,5 @@
body {
inset: 0px;
margin: 0;
padding: 0;
}