UPDATE: Webapp
This commit is contained in:
@@ -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,7 +1,6 @@
|
|||||||
#root {
|
#root {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0px;
|
inset: 0px;
|
||||||
margin: 10px;
|
margin: 0px;
|
||||||
border: 1px solid #aaa;
|
border: none;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -17,20 +25,25 @@ function App() {
|
|||||||
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());
|
||||||
}
|
}
|
||||||
@@ -41,12 +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);
|
||||||
|
}
|
||||||
@@ -1304,6 +1304,7 @@ export default class WorksheetCanvas {
|
|||||||
|
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSheet(): void {
|
renderSheet(): void {
|
||||||
console.time("renderSheet");
|
console.time("renderSheet");
|
||||||
this._renderSheet();
|
this._renderSheet();
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function rangeToStr(
|
|||||||
referenceName: string,
|
referenceName: string,
|
||||||
): string {
|
): string {
|
||||||
const { sheet, rowStart, rowEnd, columnStart, columnEnd } = range;
|
const { sheet, rowStart, rowEnd, columnStart, columnEnd } = range;
|
||||||
const sheetName = sheet === referenceSheet ? "" : `${referenceName}!`;
|
const sheetName = sheet === referenceSheet ? "" : `'${referenceName}'!`;
|
||||||
if (rowStart === rowEnd && columnStart === columnEnd) {
|
if (rowStart === rowEnd && columnStart === columnEnd) {
|
||||||
return `${sheetName}${columnNameFromNumber(columnStart)}${rowStart}`;
|
return `${sheetName}${columnNameFromNumber(columnStart)}${rowStart}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,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 {
|
||||||
|
|||||||
@@ -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,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