Compare commits
5 Commits
hackaton
...
feature/ni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99aea7b3d | ||
|
|
ac0567e897 | ||
|
|
fde1e13ffb | ||
|
|
90cf5f74f7 | ||
|
|
f53b39b220 |
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +54,53 @@ 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%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
108
webapp/src/AppComponents/FileBar.tsx
Normal file
108
webapp/src/AppComponents/FileBar.tsx
Normal 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;
|
||||||
|
`;
|
||||||
120
webapp/src/AppComponents/FileMenu.tsx
Normal file
120
webapp/src/AppComponents/FileMenu.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
28
webapp/src/AppComponents/ShareButton.tsx
Normal file
28
webapp/src/AppComponents/ShareButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
webapp/src/AppComponents/UploadFileDialog.tsx
Normal file
263
webapp/src/AppComponents/UploadFileDialog.tsx
Normal 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;
|
||||||
|
`;
|
||||||
100
webapp/src/AppComponents/WorkbookTitle.tsx
Normal file
100
webapp/src/AppComponents/WorkbookTitle.tsx
Normal 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;
|
||||||
|
`;
|
||||||
112
webapp/src/AppComponents/storage.ts
Normal file
112
webapp/src/AppComponents/storage.ts
Normal 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");
|
||||||
|
}
|
||||||
18
webapp/src/AppComponents/util.ts
Normal file
18
webapp/src/AppComponents/util.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
286
webapp/src/components/editor/editor.tsx
Normal file
286
webapp/src/components/editor/editor.tsx
Normal 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;
|
||||||
194
webapp/src/components/editor/util.tsx
Normal file
194
webapp/src/components/editor/util.tsx
Normal 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;
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
8
webapp/src/icons/orange+black.svg
Normal file
8
webapp/src/icons/orange+black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.9 KiB |
@@ -1,4 +1,5 @@
|
|||||||
body {
|
body {
|
||||||
|
inset: 0px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user