Compare commits

...

5 Commits

Author SHA1 Message Date
Nicolás Hatcher
c99aea7b3d UPDATE: Webapp 2024-10-06 16:06:56 +02:00
Nicolás Hatcher
ac0567e897 FIX: Initial browse mode within sheets 2024-09-28 15:26:55 +02:00
Nicolás Hatcher
fde1e13ffb FIX: Minimal implementation of browse mode 2024-09-28 13:55:52 +02:00
Nicolás Hatcher
90cf5f74f7 FIX: Do not loose focus when clicking on the formula we are editing 2024-09-27 19:25:26 +02:00
Nicolás Hatcher
f53b39b220 UPDATE: Adds cell and formula editing 2024-09-26 19:08:16 +02:00
28 changed files with 1882 additions and 164 deletions

View File

@@ -205,6 +205,16 @@ impl UserModel {
self.model.to_bytes() self.model.to_bytes()
} }
/// Returns the workbook name
pub fn get_name(&self) -> String {
self.model.workbook.name.clone()
}
/// Sets the name of a workbook
pub fn set_name(&mut self, name: &str) {
self.model.workbook.name = name.to_string();
}
/// Undoes last change if any, places the change in the redo list and evaluates the model if needed /// Undoes last change if any, places the change in the redo list and evaluates the model if needed
/// ///
/// See also: /// See also:

View File

@@ -37,8 +37,8 @@ pub struct Model {
#[wasm_bindgen] #[wasm_bindgen]
impl Model { impl Model {
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
pub fn new(locale: &str, timezone: &str) -> Result<Model, JsError> { pub fn new(name: &str, locale: &str, timezone: &str) -> Result<Model, JsError> {
let model = BaseModel::new_empty("workbook", locale, timezone).map_err(to_js_error)?; let model = BaseModel::new_empty(name, locale, timezone).map_err(to_js_error)?;
Ok(Model { model }) Ok(Model { model })
} }
@@ -482,4 +482,19 @@ impl Model {
.map_err(|e| to_js_error(e.to_string()))?; .map_err(|e| to_js_error(e.to_string()))?;
Ok(()) Ok(())
} }
#[wasm_bindgen(js_name = "toBytes")]
pub fn to_bytes(&self) -> Vec<u8> {
self.model.to_bytes()
}
#[wasm_bindgen(js_name = "getName")]
pub fn get_name(&self) -> String {
self.model.get_name()
}
#[wasm_bindgen(js_name = "setName")]
pub fn set_name(&mut self, name: &str) {
self.model.set_name(name);
}
} }

View File

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

View File

@@ -4,6 +4,14 @@ import "./i18n";
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import init, { Model } from "@ironcalc/wasm"; import init, { Model } from "@ironcalc/wasm";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FileBar } from "./AppComponents/FileBar";
import {
createNewModel,
loadModelFromStorageOrCreate,
saveModelToStorage,
saveSelectedModelInStorage,
selectModelFromStorage,
} from "./AppComponents/storage";
import { WorkbookState } from "./components/workbookState"; import { WorkbookState } from "./components/workbookState";
function App() { function App() {
@@ -11,25 +19,31 @@ function App() {
const [workbookState, setWorkbookState] = useState<WorkbookState | null>( const [workbookState, setWorkbookState] = useState<WorkbookState | null>(
null, null,
); );
useEffect(() => { useEffect(() => {
async function start() { async function start() {
await init(); await init();
const queryString = window.location.search; const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString); const urlParams = new URLSearchParams(queryString);
const modelName = urlParams.get("model"); const modelHash = urlParams.get("model");
// If there is a model name ?model=example.ic we try to load it // If there is a model name ?model=modelHash we try to load it
// if there is not, or the loading failed we load an empty model // if there is not, or the loading failed we load an empty model
if (modelName) { if (modelHash) {
// Get a remote model
try { try {
const model_bytes = new Uint8Array( const model_bytes = new Uint8Array(
await (await fetch(`./${modelName}`)).arrayBuffer(), await (await fetch(`/api/model/${modelHash}`)).arrayBuffer(),
); );
setModel(Model.from_bytes(model_bytes)); const importedModel = Model.from_bytes(model_bytes);
localStorage.removeItem("selected");
setModel(importedModel);
} catch (e) { } catch (e) {
setModel(new Model("en", "UTC")); alert("Model not found, or failed to load");
} }
} else { } else {
setModel(new Model("en", "UTC")); // try to load from local storage
const newModel = loadModelFromStorageOrCreate();
setModel(newModel);
} }
setWorkbookState(new WorkbookState()); setWorkbookState(new WorkbookState());
} }
@@ -40,10 +54,52 @@ function App() {
return <Loading>Loading</Loading>; return <Loading>Loading</Loading>;
} }
// We try to save the model every second
setInterval(() => {
const queue = model.flushSendQueue();
if (queue.length !== 1) {
saveSelectedModelInStorage(model);
}
}, 1000);
// We could use context for model, but the problem is that it should initialized to null. // 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. // Passing the property down makes sure it is always defined.
return <Workbook model={model} workbookState={workbookState} />;
return (
<Wrapper>
<FileBar
model={model}
onModelUpload={async (blob) => {
const bytes = new Uint8Array(await blob.arrayBuffer());
const newModel = Model.from_bytes(bytes);
saveModelToStorage(newModel);
setModel(newModel);
}}
newModel={() => {
setModel(createNewModel());
}}
setModel={(uuid: string) => {
const newModel = selectModelFromStorage(uuid);
if (newModel) {
setModel(newModel);
} }
}}
/>
<Workbook model={model} workbookState={workbookState} />
</Wrapper>
);
}
const Wrapper = styled("div")`
margin: 0px;
padding: 0px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: absolute;
`;
const Loading = styled("div")` const Loading = styled("div")`
height: 100%; height: 100%;

View File

@@ -0,0 +1,108 @@
import styled from "@emotion/styled";
import type { Model } from "@ironcalc/wasm";
import { IronCalcLogo } from "./../icons";
import { FileMenu } from "./FileMenu";
import { ShareButton } from "./ShareButton";
import { WorkbookTitle } from "./WorkbookTitle";
import { updateNameSelectedWorkbook } from "./storage";
export function FileBar(properties: {
model: Model;
newModel: () => void;
setModel: (key: string) => void;
onModelUpload: (blob: Blob) => void;
}) {
return (
<FileBarWrapper>
<IronCalcLogo style={{ width: "120px", marginLeft: "10px" }} />
<Divider />
<FileMenu
newModel={properties.newModel}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={() => {
const model = properties.model;
const arrayBuffer = model.toBytes();
const fileName = model.getName();
fetch("/api/download", {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
body: arrayBuffer,
})
.then((response) => {
if (!response.ok) throw new Error("Network response was not ok");
return response.blob();
})
.then((blob) => {
// Create a link element and trigger a download
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
// Use the same filename or change as needed
a.download = `${fileName}.xlsx`;
document.body.appendChild(a);
a.click();
// Clean up
window.URL.revokeObjectURL(url);
a.remove();
})
.catch((error) => {
console.error("Error:", error);
});
}}
/>
<WorkbookTitle
name={properties.model.getName()}
onNameChange={(name) => {
properties.model.setName(name);
updateNameSelectedWorkbook(properties.model, name);
}}
/>
<ShareButton
onClick={() => {
const model = properties.model;
const arrayBuffer = model.toBytes();
const fileName = model.getName();
fetch("/api/share", {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
body: arrayBuffer,
})
.then((response) => {
if (!response.ok) throw new Error("Network response was not ok");
return response.text();
})
.then((hash) => {
console.log(hash);
});
}}
/>
</FileBarWrapper>
);
}
const Divider = styled("div")`
margin: 10px;
height: 12px;
border-left: 1px solid #e0e0e0;
`;
const FileBarWrapper = styled("div")`
height: 60px;
width: 100%;
background: "#FFF";
display: flex;
line-height: 60px;
align-items: center;
border-bottom: 1px solid grey;
position: relative;
`;

View File

@@ -0,0 +1,120 @@
import styled from "@emotion/styled";
import { Menu, MenuItem, Modal } from "@mui/material";
import { useRef, useState } from "react";
import { UploadFileDialog } from "./UploadFileDialog";
import { getModelsMetadata, getSelectedUuuid } from "./storage";
export function FileMenu(props: {
newModel: () => void;
setModel: (key: string) => void;
onDownload: () => void;
onModelUpload: (blob: Blob) => void;
}) {
const [isMenuOpen, setMenuOpen] = useState(false);
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
const anchorElement = useRef<HTMLDivElement>(null);
const models = getModelsMetadata();
const uuids = Object.keys(models);
const selectedUuid = getSelectedUuuid();
const elements = [];
for (const uuid of uuids) {
elements.push(
<MenuItemWrapper
key={uuid}
onClick={() => {
props.setModel(uuid);
setMenuOpen(false);
}}
style={{ justifyContent: "flex-start" }}
>
<span style={{ width: "20px" }}>
{uuid === selectedUuid ? "•" : ""}
</span>
<MenuItemText>{models[uuid]}</MenuItemText>
</MenuItemWrapper>,
);
}
return (
<>
<FileMenuWrapper
onClick={(): void => setMenuOpen(true)}
ref={anchorElement}
>
File
</FileMenuWrapper>
<Menu
open={isMenuOpen}
onClose={(): void => setMenuOpen(false)}
anchorEl={anchorElement.current}
// anchorOrigin={properties.anchorOrigin}
>
<MenuItemWrapper onClick={props.newModel}>
<MenuItemText>New</MenuItemText>
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
setImportMenuOpen(true);
setMenuOpen(false);
}}
>
<MenuItemText>Import</MenuItemText>
</MenuItemWrapper>
<MenuItemWrapper>
<MenuItemText onClick={props.onDownload}>
Download (.xlsx)
</MenuItemText>
</MenuItemWrapper>
<MenuDivider />
{elements}
</Menu>
<Modal
open={isImportMenuOpen}
onClose={() => {
setImportMenuOpen(false);
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<>
<UploadFileDialog
onClose={() => setImportMenuOpen(false)}
onModelUpload={props.onModelUpload}
/>
</>
</Modal>
</>
);
}
const MenuDivider = styled("div")`
width: 80%;
margin: auto;
border-top: 1px solid #e0e0e0;
`;
const MenuItemText = styled("div")`
color: #000;
`;
const MenuItemWrapper = styled(MenuItem)`
display: flex;
justify-content: space-between;
font-size: 14px;
width: 100%;
`;
const FileMenuWrapper = styled("div")`
display: flex;
align-items: center;
font-size: 12px;
font-family: Inter;
padding: 10px;
height: 20px;
border-radius: 4px;
margin: 10px;
&:hover {
background-color: #f2f2f2;
}
`;

View File

@@ -0,0 +1,28 @@
import { Share2 } from "lucide-react";
export function ShareButton(properties: { onClick: () => void }) {
const { onClick } = properties;
return (
<div
onClick={onClick}
onKeyDown={() => {}}
style={{
position: "absolute",
right: "0px",
cursor: "pointer",
color: "#FFFFFF",
background: "#F2994A",
padding: "0px 10px",
height: "36px",
lineHeight: "36px",
borderRadius: "4px",
marginRight: "10px",
display: "flex",
alignItems: "center",
}}
>
<Share2 style={{ width: "16px", height: "16px", marginRight: "10px" }} />
<span>Share</span>
</div>
);
}

View File

@@ -0,0 +1,263 @@
import styled from "@emotion/styled";
import { BookOpen, FileUp } from "lucide-react";
import { type DragEvent, useRef, useState } from "react";
export function UploadFileDialog(properties: {
onClose: () => void;
onModelUpload: (blob: Blob) => void;
}) {
const [hover, setHover] = useState(false);
const [message, setMessage] = useState("Drop file here");
const fileInputRef = useRef<HTMLInputElement>(null);
const { onClose, onModelUpload } = properties;
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
console.log("Enter");
setHover(true);
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
// setHover(true);
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
console.log("Leave");
setHover(false);
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
console.log("Dropping");
const dt = event.dataTransfer;
const items = dt.items;
if (items) {
// Use DataTransferItemList to access the file(s)
for (let i = 0; i < items.length; i++) {
// If dropped items aren't files, skip them
if (items[i].kind === "file") {
const file = items[i].getAsFile();
if (file) {
handleFileUpload(file);
}
}
}
} else {
const files = dt.files;
for (let i = 0; i < files.length; i++) {
handleFileUpload(files[i]);
}
}
};
const handleFileUpload = (file: File) => {
setMessage(`Uploading ${file.name}...`);
// Read the file as ArrayBuffer
const reader = new FileReader();
reader.onload = () => {
const arrayBuffer = reader.result;
// Fetch request to upload the file
fetch("/api/upload", {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${file.name}"`,
},
body: arrayBuffer,
})
.then((response) => {
if (!response.ok) throw new Error("Network response was not ok");
return response.blob();
})
.then((blob) => {
setMessage(`File ${file.name} uploaded successfully!`);
onModelUpload(blob);
})
.catch((error) => {
setMessage(`Error uploading file: ${error.message}`);
});
};
reader.readAsArrayBuffer(file);
};
return (
<UploadDialog>
<UploadTitle>
<span style={{ flexGrow: 2, marginLeft: 12 }}>Import a .xlsx File</span>
<Cross
style={{ marginRight: 12 }}
onClick={onClose}
onKeyDown={() => {}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Close</title>
<path
d="M12 4.5L4 12.5"
stroke="#333333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 4.5L12 12.5"
stroke="#333333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Cross>
</UploadTitle>
<DropZone
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
// onDragLeave={handleDragLeave}
onDragExit={handleDragLeave}
onDrop={handleDrop}
>
{!hover ? (
<>
<div style={{ flexGrow: 2 }} />
<div>
<FileUp
style={{
width: 16,
color: "#EFAA6D",
backgroundColor: "#F2994A1A",
padding: "2px 4px",
borderRadius: 4,
}}
/>
</div>
<div style={{ fontSize: 12 }}>
<span style={{ color: "#333333" }}>
Drag and drop a file here or{" "}
</span>
<input
ref={fileInputRef}
type="file"
multiple
accept="*"
style={{ display: "none" }}
onChange={(event) => {
const files = event.target.files;
if (files) {
for (const file of files) {
handleFileUpload(file);
}
}
}}
/>
<DocLink
onClick={() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}}
>
click to browse
</DocLink>
</div>
<div style={{ flexGrow: 2 }} />
</>
) : (
<>
<div style={{ flexGrow: 2 }} />
<div>Drop file here</div>
<div style={{ flexGrow: 2 }} />
</>
)}
</DropZone>
<UploadFooter>
<BookOpen
style={{ width: 16, height: 16, marginLeft: 12, marginRight: 8 }}
/>
<span>Learn more about importing files into IronCalc</span>
</UploadFooter>
</UploadDialog>
);
}
const Cross = styled("div")`
&:hover {
background-color: #f5f5f5;
}
border-radius: 4px;
height: 16px;
width: 16px;
`;
const DocLink = styled("span")`
color: #f2994a;
text-decoration: underline;
`;
const UploadFooter = styled("div")`
height: 40px;
border-top: 1px solid #e0e0e0;
font-size: 12px;
font-weight: 400;
color: #757575;
display: flex;
align-items: center;
`;
const UploadTitle = styled("div")`
display: flex;
align-items: center;
border-bottom: 1px solid #e0e0e0;
height: 40px;
font-size: 14px;
font-weight: 500;
`;
const UploadDialog = styled("div")`
display: flex;
flex-direction: column;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 455px;
height: 285px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0px 1px 3px 0px #0000001a;
font-family: Inter;
`;
const DropZone = styled("div")`
flex-grow: 2;
border-radius: 10px;
text-align: center;
margin: 12px;
color: #aaa;
font-family: Arial, sans-serif;
cursor: pointer;
background-color: #faebd7;
border: 1px dashed #f2994a;
background: linear-gradient(
180deg,
rgba(242, 153, 74, 0.08) 0%,
rgba(242, 153, 74, 0) 100%
);
display: flex;
flex-direction: column;
vertical-align: center;
`;

View File

@@ -0,0 +1,100 @@
import styled from "@emotion/styled";
import { type ChangeEvent, useEffect, useRef, useState } from "react";
export function WorkbookTitle(props: {
name: string;
onNameChange: (name: string) => void;
}) {
const [width, setWidth] = useState(0);
const [value, setValue] = useState(props.name);
const mirrorDivRef = useRef<HTMLDivElement>(null);
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setValue(event.target.value);
if (mirrorDivRef.current) {
setWidth(mirrorDivRef.current.scrollWidth);
}
};
useEffect(() => {
if (mirrorDivRef.current) {
setWidth(mirrorDivRef.current.scrollWidth);
}
}, []);
useEffect(() => {
setValue(props.name);
}, [props.name]);
return (
<div
style={{
position: "absolute",
left: "50%",
textAlign: "center",
transform: "translateX(-50%)",
// height: "60px",
// lineHeight: "60px",
padding: "8px",
fontSize: "14px",
fontWeight: "700",
fontFamily: "Inter",
width,
}}
>
<TitleWrapper
value={value}
rows={1}
onChange={handleChange}
onBlur={(event) => {
props.onNameChange(event.target.value);
}}
style={{ width: width }}
spellCheck="false"
>
{value}
</TitleWrapper>
<div
ref={mirrorDivRef}
style={{
position: "absolute",
top: "-9999px",
left: "-9999px",
whiteSpace: "pre-wrap",
textWrap: "nowrap",
visibility: "hidden",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
padding: "inherit",
border: "inherit",
}}
>
{value}
</div>
</div>
);
}
const TitleWrapper = styled("textarea")`
vertical-align: middle;
text-align: center;
height: 20px;
line-height: 20px;
border-radius: 4px;
padding: inherit;
overflow: hidden;
outline: none;
resize: none;
text-wrap: nowrap;
border: none;
&:hover {
background-color: #f2f2f2;
}
&:focus {
border: 1px solid grey;
}
font-weight: inherit;
font-family: inherit;
font-size: inherit;
`;

View File

@@ -0,0 +1,112 @@
import { Model } from "@ironcalc/wasm";
import { base64ToBytes, bytesToBase64 } from "./util";
const MAX_WORKBOOKS = 50;
type ModelsMetadata = Record<string, string>;
export function updateNameSelectedWorkbook(model: Model, newName: string) {
const uuid = localStorage.getItem("selected");
if (uuid) {
const modelsJson = localStorage.getItem("models");
if (modelsJson) {
try {
const models = JSON.parse(modelsJson);
models[uuid] = newName;
localStorage.setItem("models", JSON.stringify(models));
} catch (e) {
console.warn("Failed saving new name");
}
}
const modeBytes = model.toBytes();
localStorage.setItem(uuid, bytesToBase64(modeBytes));
}
}
export function getModelsMetadata(): ModelsMetadata {
let modelsJson = localStorage.getItem("models");
if (!modelsJson) {
modelsJson = "{}";
}
return JSON.parse(modelsJson);
}
// Pick a different name Workbook{N} where N = 1, 2, 3
function getNewName(existingNames: string[]): string {
const baseName = "Workbook";
let index = 1;
while (index < MAX_WORKBOOKS) {
const name = `${baseName}${index}`;
index += 1;
if (!existingNames.includes(name)) {
return name;
}
}
// FIXME: Too many workbooks?
return "Workbook-Infinity";
}
export function createNewModel(): Model {
const models = getModelsMetadata();
const name = getNewName(Object.values(models));
const model = new Model(name, "en", "UTC");
const uuid = crypto.randomUUID();
localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
models[uuid] = name;
localStorage.setItem("models", JSON.stringify(models));
return model;
}
export function loadModelFromStorageOrCreate(): Model {
const uuid = localStorage.getItem("selected");
if (uuid) {
// We try to load the selected model
const modelBytesString = localStorage.getItem(uuid);
if (modelBytesString) {
return Model.from_bytes(base64ToBytes(modelBytesString));
}
// If it doesn't exist we create one at that uuid
const newModel = new Model("Workbook1", "en", "UTC");
localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(newModel.toBytes()));
return newModel;
}
// If there was no selected model we create a new one
return createNewModel();
}
export function saveSelectedModelInStorage(model: Model) {
const uuid = localStorage.getItem("selected");
if (uuid) {
const modeBytes = model.toBytes();
localStorage.setItem(uuid, bytesToBase64(modeBytes));
}
}
export function saveModelToStorage(model: Model) {
const uuid = crypto.randomUUID();
localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
let modelsJson = localStorage.getItem("models");
if (!modelsJson) {
modelsJson = "{}";
}
const models = JSON.parse(modelsJson);
models[uuid] = model.getName();
localStorage.setItem("models", JSON.stringify(models));
}
export function selectModelFromStorage(uuid: string): Model | undefined {
localStorage.setItem("selected", uuid);
const modelBytesString = localStorage.getItem(uuid);
if (modelBytesString) {
return Model.from_bytes(base64ToBytes(modelBytesString));
}
}
export function getSelectedUuuid(): string | null {
return localStorage.getItem("selected");
}

View File

@@ -0,0 +1,18 @@
export function base64ToBytes(base64: string): Uint8Array {
// const binString = atob(base64);
// return Uint8Array.from(binString, (m) => m.codePointAt(0));
return new Uint8Array(
atob(base64)
.split("")
.map((c) => c.charCodeAt(0)),
);
}
export function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte),
).join("");
// btoa(String.fromCharCode(...bytes));
return btoa(binString);
}

View File

@@ -30,6 +30,7 @@ export interface CanvasSettings {
columnGuide: HTMLDivElement; columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement; rowGuide: HTMLDivElement;
columnHeaders: HTMLDivElement; columnHeaders: HTMLDivElement;
editor: HTMLDivElement;
}; };
onColumnWidthChanges: (sheet: number, column: number, width: number) => void; onColumnWidthChanges: (sheet: number, column: number, width: number) => void;
onRowHeightChanges: (sheet: number, row: number, height: 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 headerFontFamily = fonts.regular;
export const frozenSeparatorWidth = 3; 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 { export default class WorksheetCanvas {
sheetWidth: number; sheetWidth: number;
@@ -61,6 +79,8 @@ export default class WorksheetCanvas {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
editor: HTMLDivElement;
areaOutline: HTMLDivElement; areaOutline: HTMLDivElement;
cellOutline: HTMLDivElement; cellOutline: HTMLDivElement;
@@ -92,6 +112,7 @@ export default class WorksheetCanvas {
this.height = options.height; this.height = options.height;
this.ctx = this.setContext(); this.ctx = this.setContext();
this.workbookState = options.workbookState; this.workbookState = options.workbookState;
this.editor = options.elements.editor;
this.cellOutline = options.elements.cellOutline; this.cellOutline = options.elements.cellOutline;
this.cellOutlineHandle = options.elements.cellOutlineHandle; this.cellOutlineHandle = options.elements.cellOutlineHandle;
@@ -1092,7 +1113,7 @@ export default class WorksheetCanvas {
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, areaOutline, cellOutlineHandle } = this; const { cellOutline, editor, areaOutline, cellOutlineHandle } = this;
const cellEditing = null; const cellEditing = null;
cellOutline.style.visibility = "visible"; cellOutline.style.visibility = "visible";
@@ -1105,6 +1126,19 @@ 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}px`; cellOutline.style.left = `${x - padding}px`;
cellOutline.style.top = `${y - padding}px`; cellOutline.style.top = `${y - padding}px`;
@@ -1214,6 +1248,63 @@ export default class WorksheetCanvas {
cellOutlineHandle.style.top = `${handleY - handleHeight / 2}px`; cellOutlineHandle.style.top = `${handleY - handleHeight / 2}px`;
} }
private drawActiveRanges(topLeftCell: Cell, bottomRightCell: Cell): void {
let activeRanges = this.workbookState.getActiveRanges();
const ctx = this.ctx;
ctx.setLineDash([2, 2]);
const referencedRange =
this.workbookState.getEditingCell()?.referencedRange || null;
if (referencedRange) {
activeRanges = activeRanges.concat([
{
...referencedRange.range,
color: "#343423",
},
]);
}
const activeRangesCount = activeRanges.length;
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 { renderSheet(): void {
console.time("renderSheet"); console.time("renderSheet");
this._renderSheet(); this._renderSheet();
@@ -1352,5 +1443,6 @@ export default class WorksheetCanvas {
this.drawCellOutline(); this.drawCellOutline();
this.drawExtendToArea(); this.drawExtendToArea();
this.drawActiveRanges(topLeftCell, bottomRightCell);
} }
} }

View File

@@ -0,0 +1,286 @@
// 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
//
// 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
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) {
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],
);
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) {
model.setUserInput(
cell.sheet,
cell.row,
cell.column,
workbookState.getEditingText(),
);
workbookState.clearEditingCell();
}
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,
overflow: "hidden",
}}
defaultValue={text}
spellCheck="false"
onKeyDown={onKeyDown}
onBlur={onChange}
onClick={(event) => {
// Prevents this from bubbling up and focusing on the spreadsheet
if (isCellEditing && type === "cell") {
event.stopPropagation();
}
}}
/>
</div>
);
};
export default Editor;

View File

@@ -0,0 +1,194 @@
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;
}
export 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,
referenceRange: 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>);
}
}
// If there is a reference range add it at the end
if (referenceRange !== "") {
html.push(<span key="reference">{referenceRange}</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,35 @@
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 { useState } from "react";
import { Fx } from "../icons"; import { Fx } from "../icons";
import { FormulaDialog } from "./formulaDialog"; import Editor from "./editor/editor";
import type { WorkbookState } from "./workbookState";
type FormulaBarProps = { type FormulaBarProps = {
cellAddress: string; cellAddress: string;
formulaValue: string; formulaValue: string;
onChange: (value: string) => void; model: Model;
workbookState: WorkbookState;
onChange: () => void;
onTextUpdated: () => void;
}; };
const formulaBarHeight = 30; const formulaBarHeight = 30;
const headerColumnWidth = 30; const headerColumnWidth = 35;
function FormulaBar(properties: FormulaBarProps) { function FormulaBar(properties: FormulaBarProps) {
const [formulaDialogOpen, setFormulaDialogOpen] = useState(false); const {
const handleCloseFormulaDialog = () => { cellAddress,
setFormulaDialogOpen(false); formulaValue,
}; model,
onChange,
onTextUpdated,
workbookState,
} = properties;
return ( return (
<Container> <Container>
<AddressContainer> <AddressContainer>
<CellBarAddress>{properties.cellAddress}</CellBarAddress> <CellBarAddress>{cellAddress}</CellBarAddress>
<StyledButton> <StyledButton>
<ChevronDown /> <ChevronDown />
</StyledButton> </StyledButton>
@@ -32,23 +39,40 @@ function FormulaBar(properties: FormulaBarProps) {
<FormulaSymbolButton> <FormulaSymbolButton>
<Fx /> <Fx />
</FormulaSymbolButton> </FormulaSymbolButton>
<Editor <EditorWrapper
onClick={() => { onClick={(event) => {
setFormulaDialogOpen(true); const [sheet, row, column] = model.getSelectedCell();
workbookState.setEditingCell({
sheet,
row,
column,
text: formulaValue,
referencedRange: null,
cursorStart: formulaValue.length,
cursorEnd: formulaValue.length,
focus: "formula-bar",
activeRanges: [],
});
event.stopPropagation();
event.preventDefault();
}} }}
> >
{properties.formulaValue} <Editor
</Editor> minimalWidth={"100%"}
</FormulaContainer> minimalHeight={"100%"}
<FormulaDialog display={true}
isOpen={formulaDialogOpen} expand={false}
close={handleCloseFormulaDialog} originalText={formulaValue}
defaultFormula={properties.formulaValue} model={model}
onFormulaChanged={(newName) => { workbookState={workbookState}
properties.onChange(newName); onEditEnd={() => {
setFormulaDialogOpen(false); onChange();
}} }}
onTextUpdated={onTextUpdated}
type="formula-bar"
/> />
</EditorWrapper>
</FormulaContainer>
</Container> </Container>
); );
} }
@@ -113,7 +137,7 @@ const CellBarAddress = styled("div")`
text-align: "center"; text-align: "center";
`; `;
const Editor = styled("div")` const EditorWrapper = styled("div")`
position: relative; position: relative;
width: 100%; width: 100%;
padding: 0px; padding: 0px;
@@ -127,6 +151,7 @@ const Editor = styled("div")`
span { span {
min-width: 1px; min-width: 1px;
} }
font-family: monospace;
`; `;
export default FormulaBar; export default FormulaBar;

View File

@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Menu, Plus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyledButton } from "../toolbar"; import { StyledButton } from "../toolbar";
import type { WorkbookState } from "../workbookState";
import SheetListMenu from "./menus"; import SheetListMenu from "./menus";
import Sheet from "./sheet"; import Sheet from "./sheet";
import type { SheetOptions } from "./types"; import type { SheetOptions } from "./types";
@@ -10,6 +11,7 @@ import type { SheetOptions } from "./types";
export interface NavigationProps { export interface NavigationProps {
sheets: SheetOptions[]; sheets: SheetOptions[];
selectedIndex: number; selectedIndex: number;
workbookState: WorkbookState;
onSheetSelected: (index: number) => void; onSheetSelected: (index: number) => void;
onAddBlankSheet: () => void; onAddBlankSheet: () => void;
onSheetColorChanged: (hex: string) => void; onSheetColorChanged: (hex: string) => void;
@@ -19,7 +21,7 @@ export interface NavigationProps {
function Navigation(props: NavigationProps) { function Navigation(props: NavigationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { onSheetSelected, sheets, selectedIndex } = props; const { workbookState, onSheetSelected, sheets, selectedIndex } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -63,6 +65,7 @@ function Navigation(props: NavigationProps) {
onDeleted={(): void => { onDeleted={(): void => {
props.onSheetDeleted(); props.onSheetDeleted();
}} }}
workbookState={workbookState}
/> />
))} ))}
</SheetInner> </SheetInner>

View File

@@ -2,7 +2,10 @@ import { Button, Menu, MenuItem, styled } from "@mui/material";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import ColorPicker from "../colorPicker"; import ColorPicker from "../colorPicker";
import { isInReferenceMode } from "../editor/util";
import type { WorkbookState } from "../workbookState";
import { SheetRenameDialog } from "./menus"; import { SheetRenameDialog } from "./menus";
interface SheetProps { interface SheetProps {
name: string; name: string;
color: string; color: string;
@@ -11,9 +14,11 @@ interface SheetProps {
onColorChanged: (hex: string) => void; onColorChanged: (hex: string) => void;
onRenamed: (name: string) => void; onRenamed: (name: string) => void;
onDeleted: () => void; onDeleted: () => void;
workbookState: WorkbookState;
} }
function Sheet(props: SheetProps) { function Sheet(props: SheetProps) {
const { name, color, selected, onSelected } = props; const { name, color, selected, workbookState, onSelected } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const [colorPickerOpen, setColorPickerOpen] = useState(false); const [colorPickerOpen, setColorPickerOpen] = useState(false);
const colorButton = useRef(null); const colorButton = useRef(null);
@@ -35,8 +40,18 @@ function Sheet(props: SheetProps) {
<> <>
<Wrapper <Wrapper
style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }} style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }}
onClick={() => { onClick={(event) => {
onSelected(); onSelected();
event.stopPropagation();
event.preventDefault();
}}
onPointerDown={(event) => {
// If it is in browse mode stop he event
const cell = workbookState.getEditingCell();
if (cell && isInReferenceMode(cell.text, cell.cursorStart)) {
event.stopPropagation();
event.preventDefault();
}
}} }}
ref={colorButton} ref={colorButton}
> >

View File

@@ -7,7 +7,7 @@ import { expect, test } from "vitest";
test("simple calculation", async () => { test("simple calculation", async () => {
const buffer = await readFile("node_modules/@ironcalc/wasm/wasm_bg.wasm"); const buffer = await readFile("node_modules/@ironcalc/wasm/wasm_bg.wasm");
initSync(buffer); initSync(buffer);
const model = new Model("en", "UTC"); const model = new Model("workbook", "en", "UTC");
model.setUserInput(0, 1, 1, "=21*2"); model.setUserInput(0, 1, 1, "=21*2");
expect(model.getFormattedCellValue(0, 1, 1)).toBe("42"); expect(model.getFormattedCellValue(0, 1, 1)).toBe("42");
}); });

View File

@@ -1,10 +1,14 @@
import type { Model } from "@ironcalc/wasm";
import { type PointerEvent, type RefObject, useCallback, useRef } from "react"; import { type PointerEvent, type RefObject, useCallback, useRef } from "react";
import type WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas"; import type WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
import { import {
headerColumnWidth, headerColumnWidth,
headerRowHeight, headerRowHeight,
} from "./WorksheetCanvas/worksheetCanvas"; } from "./WorksheetCanvas/worksheetCanvas";
import { isInReferenceMode } from "./editor/util";
import type { Cell } from "./types"; import type { Cell } from "./types";
import { rangeToStr } from "./util";
import type { WorkbookState } from "./workbookState";
interface PointerSettings { interface PointerSettings {
canvasElement: RefObject<HTMLCanvasElement>; canvasElement: RefObject<HTMLCanvasElement>;
@@ -15,6 +19,9 @@ interface PointerSettings {
onAreaSelected: () => void; onAreaSelected: () => void;
onExtendToCell: (cell: Cell) => void; onExtendToCell: (cell: Cell) => void;
onExtendToEnd: () => void; onExtendToEnd: () => void;
model: Model;
workbookState: WorkbookState;
refresh: () => void;
} }
interface PointerEvents { interface PointerEvents {
@@ -27,6 +34,7 @@ interface PointerEvents {
const usePointer = (options: PointerSettings): PointerEvents => { const usePointer = (options: PointerSettings): PointerEvents => {
const isSelecting = useRef(false); const isSelecting = useRef(false);
const isExtending = useRef(false); const isExtending = useRef(false);
const isInsertingRef = useRef(false);
const onPointerMove = useCallback( const onPointerMove = useCallback(
(event: PointerEvent): void => { (event: PointerEvent): void => {
@@ -36,43 +44,50 @@ const usePointer = (options: PointerSettings): PointerEvents => {
return; return;
} }
if (isSelecting.current) { if (
const { canvasElement, worksheetCanvas } = options; !(isSelecting.current || isExtending.current || isInsertingRef.current)
) {
return;
}
const { canvasElement, model, worksheetCanvas } = options;
const canvas = canvasElement.current; const canvas = canvasElement.current;
const worksheet = worksheetCanvas.current; const worksheet = worksheetCanvas.current;
// Silence the linter // Silence the linter
if (!worksheet || !canvas) { if (!worksheet || !canvas) {
return; return;
} }
let x = event.clientX;
let y = event.clientY;
const canvasRect = canvas.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect();
x -= canvasRect.x; const x = event.clientX - canvasRect.x;
y -= canvasRect.y; const y = event.clientY - canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (cell) {
options.onAreaSelecting(cell);
} else {
console.log("Failed");
}
} else if (isExtending.current) {
const { canvasElement, worksheetCanvas } = options;
const canvas = canvasElement.current;
const worksheet = worksheetCanvas.current;
// Silence the linter
if (!worksheet || !canvas) {
return;
}
let x = event.clientX;
let y = event.clientY;
const canvasRect = canvas.getBoundingClientRect();
x -= canvasRect.x;
y -= canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y); const cell = worksheet.getCellByCoordinates(x, y);
if (!cell) { if (!cell) {
return; return;
} }
if (isSelecting.current) {
options.onAreaSelecting(cell);
} else if (isExtending.current) {
options.onExtendToCell(cell); options.onExtendToCell(cell);
} else if (isInsertingRef.current) {
const { refresh, workbookState } = options;
const editingCell = workbookState.getEditingCell();
if (!editingCell || !editingCell.referencedRange) {
return;
}
const range = editingCell.referencedRange.range;
range.rowEnd = cell.row;
range.columnEnd = cell.column;
const sheetNames = model.getWorksheetsProperties().map((s) => s.name);
editingCell.referencedRange.str = rangeToStr(
range,
editingCell.sheet,
sheetNames[range.sheet],
);
workbookState.setEditingCell(editingCell);
refresh();
} }
}, },
[options], [options],
@@ -90,6 +105,10 @@ const usePointer = (options: PointerSettings): PointerEvents => {
isExtending.current = false; isExtending.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId); worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onExtendToEnd(); options.onExtendToEnd();
} else if (isInsertingRef.current) {
const { worksheetElement } = options;
isInsertingRef.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId);
} }
}, },
[options], [options],
@@ -99,7 +118,14 @@ const usePointer = (options: PointerSettings): PointerEvents => {
(event: PointerEvent) => { (event: PointerEvent) => {
let x = event.clientX; let x = event.clientX;
let y = event.clientY; let y = event.clientY;
const { canvasElement, worksheetElement, worksheetCanvas } = options; const {
canvasElement,
model,
refresh,
worksheetElement,
worksheetCanvas,
workbookState,
} = options;
const worksheet = worksheetCanvas.current; const worksheet = worksheetCanvas.current;
const canvas = canvasElement.current; const canvas = canvasElement.current;
const worksheetWrapper = worksheetElement.current; const worksheetWrapper = worksheetElement.current;
@@ -132,8 +158,60 @@ const usePointer = (options: PointerSettings): PointerEvents => {
} }
return; return;
} }
const editingCell = workbookState.getEditingCell();
const cell = worksheet.getCellByCoordinates(x, y); const cell = worksheet.getCellByCoordinates(x, y);
if (cell) { if (cell) {
if (editingCell) {
if (
cell.row === editingCell.row &&
cell.column === editingCell.column
) {
// We are clicking on the cell we are editing
// we do nothing
return;
}
// now we are editing one cell and we click in another one
// If we can insert a range we do that
const text = editingCell.text;
if (isInReferenceMode(text, editingCell.cursorEnd)) {
const range = {
sheet: model.getSelectedSheet(),
rowStart: cell.row,
rowEnd: cell.row,
columnStart: cell.column,
columnEnd: cell.column,
};
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
editingCell.referencedRange = {
range,
str: rangeToStr(
range,
editingCell.sheet,
sheetNames[range.sheet],
),
};
workbookState.setEditingCell(editingCell);
event.stopPropagation();
event.preventDefault();
isInsertingRef.current = true;
worksheetWrapper.setPointerCapture(event.pointerId);
refresh();
return;
}
// We are clicking away but we are not in reference mode
// We finish the editing
workbookState.clearEditingCell();
model.setUserInput(
editingCell.sheet,
editingCell.row,
editingCell.column,
editingCell.text,
);
// we continue to select the new cell
}
options.onCellSelected(cell, event); options.onCellSelected(cell, event);
isSelecting.current = true; isSelecting.current = true;
worksheetWrapper.setPointerCapture(event.pointerId); worksheetWrapper.setPointerCapture(event.pointerId);

View File

@@ -40,3 +40,22 @@ export const getCellAddress = (selectedArea: Area, selectedCell?: Cell) => {
selectedArea.rowStart selectedArea.rowStart
}:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`; }:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`;
}; };
export function rangeToStr(
range: {
sheet: number;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
},
referenceSheet: number,
referenceName: string,
): string {
const { sheet, rowStart, rowEnd, columnStart, columnEnd } = range;
const sheetName = sheet === referenceSheet ? "" : `'${referenceName}'!`;
if (rowStart === rowEnd && columnStart === columnEnd) {
return `${sheetName}${columnNameFromNumber(columnStart)}${rowStart}`;
}
return `${sheetName}${columnNameFromNumber(columnStart)}${rowStart}:${columnNameFromNumber(columnEnd)}${rowEnd}`;
}

View File

@@ -1,6 +1,6 @@
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 { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { LAST_COLUMN } from "./WorksheetCanvas/constants"; import { LAST_COLUMN } from "./WorksheetCanvas/constants";
import FormulaBar from "./formulabar"; import FormulaBar from "./formulabar";
import Navigation from "./navigation/navigation"; import Navigation from "./navigation/navigation";
@@ -143,11 +143,35 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}, },
onEditKeyPressStart: (initText: string): void => { onEditKeyPressStart: (initText: string): void => {
console.log(initText); const { sheet, row, column } = model.getSelectedView();
throw new Error("Function not implemented."); workbookState.setEditingCell({
sheet,
row,
column,
text: initText,
cursorStart: initText.length,
cursorEnd: initText.length,
focus: "cell",
referencedRange: null,
activeRanges: [],
});
setRedrawId((id) => id + 1);
}, },
onCellEditStart: (): void => { 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,
cursorStart: text.length,
cursorEnd: text.length,
referencedRange: null,
focus: "cell",
activeRanges: [],
});
setRedrawId((id) => id + 1);
}, },
onBold: () => { onBold: () => {
const { sheet, row, column } = model.getSelectedView(); const { sheet, row, column } = model.getSelectedView();
@@ -237,26 +261,52 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
if (!rootRef.current) { if (!rootRef.current) {
return; return;
} }
if (!workbookState.getEditingCell()) {
rootRef.current.focus(); rootRef.current.focus();
}
}); });
const cellAddress = useCallback(() => {
const { const {
sheet,
row, row,
column, column,
range: [rowStart, columnStart, rowEnd, columnEnd], range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView(); } = model.getSelectedView();
return getCellAddress(
const cellAddress = getCellAddress(
{ rowStart, rowEnd, columnStart, columnEnd }, { rowStart, rowEnd, columnStart, columnEnd },
{ row, column }, { row, column },
); );
const formulaValue = model.getCellContent(sheet, row, column); }, [model]);
const style = model.getCellStyle(sheet, row, column); const formulaValue = () => {
const cell = workbookState.getEditingCell();
if (cell) {
return workbookState.getEditingText();
}
const { sheet, row, column } = model.getSelectedView();
return model.getCellContent(sheet, row, column);
};
const getCellStyle = useCallback(() => {
const { sheet, row, column } = model.getSelectedView();
return model.getCellStyle(sheet, row, column);
}, [model]);
const style = getCellStyle();
return ( return (
<Container ref={rootRef} onKeyDown={onKeyDown} tabIndex={0}> <Container
ref={rootRef}
onKeyDown={onKeyDown}
tabIndex={0}
onClick={(event) => {
if (!workbookState.getEditingCell()) {
rootRef.current?.focus();
} else {
event.stopPropagation();
}
}}
>
<Toolbar <Toolbar
canUndo={model.canUndo()} canUndo={model.canUndo()}
canRedo={model.canRedo()} canRedo={model.canRedo()}
@@ -304,19 +354,25 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
verticalAlign={style.alignment ? style.alignment.vertical : "center"} verticalAlign={style.alignment ? style.alignment.vertical : "center"}
canEdit={true} canEdit={true}
numFmt={style.num_fmt} numFmt={style.num_fmt}
showGridLines={model.getShowGridLines(sheet)} showGridLines={model.getShowGridLines(model.getSelectedSheet())}
onToggleShowGridLines={(show) => { onToggleShowGridLines={(show) => {
const sheet = model.getSelectedSheet();
model.setShowGridLines(sheet, show); model.setShowGridLines(sheet, show);
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}} }}
/> />
<FormulaBar <FormulaBar
cellAddress={cellAddress} cellAddress={cellAddress()}
formulaValue={formulaValue} formulaValue={formulaValue()}
onChange={(value) => { onChange={() => {
model.setUserInput(sheet, row, column, value); setRedrawId((id) => id + 1);
rootRef.current?.focus();
}}
onTextUpdated={() => {
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}} }}
model={model}
workbookState={workbookState}
/> />
<Worksheet <Worksheet
model={model} model={model}
@@ -325,9 +381,11 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}} }}
/> />
<Navigation <Navigation
sheets={info} sheets={info}
selectedIndex={model.getSelectedSheet()} selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}
onSheetSelected={(sheet: number): void => { onSheetSelected={(sheet: number): void => {
model.setSelectedSheet(sheet); model.setSelectedSheet(sheet);
setRedrawId((value) => value + 1); setRedrawId((value) => value + 1);
@@ -368,6 +426,7 @@ const Container = styled("div")`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative;
font-family: ${({ theme }) => theme.typography.fontFamily}; font-family: ${({ theme }) => theme.typography.fontFamily};
&:focus { &:focus {

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"; import type { CellStyle } from "@ironcalc/wasm";
export enum AreaType { export enum AreaType {
@@ -15,15 +30,58 @@ export interface Area {
columnEnd: number; 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;
}
export interface ReferencedRange {
range: {
sheet: number;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
};
str: 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
cursorStart: number;
cursorEnd: number;
// referenced range
referencedRange: ReferencedRange | null;
focus: Focus;
activeRanges: ActiveRange[];
}
// Those are styles that are copied
type AreaStyles = CellStyle[][]; type AreaStyles = CellStyle[][];
export class WorkbookState { export class WorkbookState {
private extendToArea: Area | null; private extendToArea: Area | null;
private copyStyles: AreaStyles | null; private copyStyles: AreaStyles | null;
private cell: EditingCell | null;
constructor() { constructor() {
// the extendTo area is the area we are covering
this.extendToArea = null; this.extendToArea = null;
this.copyStyles = null; this.copyStyles = null;
this.cell = null;
} }
getExtendToArea(): Area | null { getExtendToArea(): Area | null {
@@ -45,4 +103,49 @@ export class WorkbookState {
getCopyStyles(): AreaStyles | null { getCopyStyles(): AreaStyles | null {
return this.copyStyles; 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;
}
getEditingText(): string {
const cell = this.cell;
if (cell) {
return cell.text + (cell.referencedRange?.str || "");
}
return "";
}
} }

View File

@@ -6,6 +6,7 @@ import {
outlineColor, outlineColor,
} from "./WorksheetCanvas/constants"; } from "./WorksheetCanvas/constants";
import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas"; import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
import Editor from "./editor/editor";
import type { Cell } from "./types"; import type { Cell } from "./types";
import usePointer from "./usePointer"; import usePointer from "./usePointer";
import { AreaType, type WorkbookState } from "./workbookState"; import { AreaType, type WorkbookState } from "./workbookState";
@@ -32,7 +33,8 @@ function Worksheet(props: {
const worksheetElement = useRef<HTMLDivElement>(null); const worksheetElement = useRef<HTMLDivElement>(null);
const scrollElement = useRef<HTMLDivElement>(null); const scrollElement = useRef<HTMLDivElement>(null);
// const rootElement = useRef<HTMLDivElement>(null);
const editorElement = useRef<HTMLDivElement>(null);
const spacerElement = useRef<HTMLDivElement>(null); const spacerElement = useRef<HTMLDivElement>(null);
const cellOutline = useRef<HTMLDivElement>(null); const cellOutline = useRef<HTMLDivElement>(null);
const areaOutline = useRef<HTMLDivElement>(null); const areaOutline = useRef<HTMLDivElement>(null);
@@ -45,8 +47,11 @@ function Worksheet(props: {
const ignoreScrollEventRef = useRef(false); const ignoreScrollEventRef = useRef(false);
const [originalText, setOriginalText] = useState("");
const { model, workbookState, refresh } = props; const { model, workbookState, refresh } = props;
const [clientWidth, clientHeight] = useWindowSize(); const [clientWidth, clientHeight] = useWindowSize();
useEffect(() => { useEffect(() => {
const canvasRef = canvasElement.current; const canvasRef = canvasElement.current;
const columnGuideRef = columnResizeGuide.current; const columnGuideRef = columnResizeGuide.current;
@@ -58,6 +63,7 @@ function Worksheet(props: {
const handle = cellOutlineHandle.current; const handle = cellOutlineHandle.current;
const area = areaOutline.current; const area = areaOutline.current;
const extendTo = extendToOutline.current; const extendTo = extendToOutline.current;
const editor = editorElement.current;
if ( if (
!canvasRef || !canvasRef ||
@@ -69,7 +75,8 @@ function Worksheet(props: {
!handle || !handle ||
!area || !area ||
!extendTo || !extendTo ||
!scrollElement.current !scrollElement.current ||
!editor
) )
return; return;
model.setWindowWidth(clientWidth - 37); model.setWindowWidth(clientWidth - 37);
@@ -88,6 +95,7 @@ function Worksheet(props: {
cellOutlineHandle: handle, cellOutlineHandle: handle,
areaOutline: area, areaOutline: area,
extendToOutline: extendTo, extendToOutline: extendTo,
editor: editor,
}, },
onColumnWidthChanges(sheet, column, width) { onColumnWidthChanges(sheet, column, width) {
model.setColumnWidth(sheet, column, width); model.setColumnWidth(sheet, column, width);
@@ -100,7 +108,7 @@ function Worksheet(props: {
}); });
const scrollX = model.getScrollX(); const scrollX = model.getScrollX();
const scrollY = model.getScrollY(); 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) { if (spacerElement.current) {
spacerElement.current.style.height = `${sheetHeight}px`; spacerElement.current.style.height = `${sheetHeight}px`;
spacerElement.current.style.width = `${sheetWidth}px`; spacerElement.current.style.width = `${sheetWidth}px`;
@@ -127,10 +135,6 @@ function Worksheet(props: {
worksheetCanvas.current = canvas; worksheetCanvas.current = canvas;
}); });
const sheetNames = model
.getWorksheetsProperties()
.map((s: { name: string }) => s.name);
const { const {
onPointerMove, onPointerMove,
onPointerDown, onPointerDown,
@@ -138,6 +142,9 @@ function Worksheet(props: {
onPointerUp, onPointerUp,
// onContextMenu, // onContextMenu,
} = usePointer({ } = usePointer({
model,
workbookState,
refresh,
onCellSelected: (cell: Cell, event: React.MouseEvent) => { onCellSelected: (cell: Cell, event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -310,21 +317,49 @@ function Worksheet(props: {
<SheetContainer <SheetContainer
className="sheet-container" className="sheet-container"
ref={worksheetElement} ref={worksheetElement}
onPointerDown={(event) => { onPointerDown={onPointerDown}
onPointerDown(event);
}}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onDoubleClick={(event) => { onDoubleClick={(event) => {
// 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) || "";
// TODO workbookState.setEditingCell({
sheet,
row,
column,
text,
cursorStart: text.length,
cursorEnd: text.length,
focus: "cell",
referencedRange: null,
activeRanges: [],
});
setOriginalText(text);
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
}} }}
> >
<SheetCanvas ref={canvasElement} /> <SheetCanvas ref={canvasElement} />
<CellOutline ref={cellOutline} /> <CellOutline ref={cellOutline} />
<EditorWrapper ref={editorElement}>
<Editor
minimalWidth={"100%"}
minimalHeight={"100%"}
display={workbookState.getEditingCell()?.focus === "cell"}
expand={true}
originalText={workbookState.getEditingText() || originalText}
onEditEnd={(): void => {
props.refresh();
}}
onTextUpdated={(): void => {
props.refresh();
}}
model={model}
workbookState={workbookState}
type={"cell"}
/>
</EditorWrapper>
<AreaOutline ref={areaOutline} /> <AreaOutline ref={areaOutline} />
<ExtendToOutline ref={extendToOutline} /> <ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle <CellOutlineHandle
@@ -461,4 +496,21 @@ const ExtendToOutline = styled("div")`
border-radius: 3px; 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; export default Worksheet;

View File

@@ -20,6 +20,8 @@ import InsertColumnRightIcon from "./insert-column-right.svg?react";
import InsertRowAboveIcon from "./insert-row-above.svg?react"; import InsertRowAboveIcon from "./insert-row-above.svg?react";
import InsertRowBelow from "./insert-row-below.svg?react"; import InsertRowBelow from "./insert-row-below.svg?react";
import IronCalcLogo from "./orange+black.svg?react";
import Fx from "./fx.svg?react"; import Fx from "./fx.svg?react";
export { export {
@@ -42,5 +44,6 @@ export {
InsertColumnRightIcon, InsertColumnRightIcon,
InsertRowAboveIcon, InsertRowAboveIcon,
InsertRowBelow, InsertRowBelow,
IronCalcLogo,
Fx, Fx,
}; };

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

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

View File

@@ -1,8 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import "./index.css"; import "./index.css";
import ThemeProvider from "@mui/material/styles/ThemeProvider"; import ThemeProvider from "@mui/material/styles/ThemeProvider";
import React from "react";
import { theme } from "./theme.ts"; import { theme } from "./theme.ts";
// biome-ignore lint: we know the 'root' element exists. // biome-ignore lint: we know the 'root' element exists.