UPDATE: Adds cell and formula editing (#92)
* UPDATE: Adds cell and formula editing * FIX: Do not loose focus when clicking on the formula we are editing * FIX: Minimal implementation of browse mode * FIX: Initial browse mode within sheets * UPDATE: Webapp Minimal Web Application
This commit is contained in:
committed by
GitHub
parent
53d3d5144c
commit
48719b6416
103
webapp/src/AppComponents/FileBar.tsx
Normal file
103
webapp/src/AppComponents/FileBar.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import styled from "@emotion/styled";
|
||||
import type { Model } from "@ironcalc/wasm";
|
||||
import { CircleCheck } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { IronCalcLogo } from "./../icons";
|
||||
import { FileMenu } from "./FileMenu";
|
||||
import { ShareButton } from "./ShareButton";
|
||||
import { WorkbookTitle } from "./WorkbookTitle";
|
||||
import { downloadModel, shareModel } from "./rpc";
|
||||
import { updateNameSelectedWorkbook } from "./storage";
|
||||
|
||||
export function FileBar(properties: {
|
||||
model: Model;
|
||||
newModel: () => void;
|
||||
setModel: (key: string) => void;
|
||||
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
}) {
|
||||
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
||||
const [toast, setToast] = useState(false);
|
||||
return (
|
||||
<FileBarWrapper>
|
||||
<IronCalcLogo style={{ width: "120px", marginLeft: "10px" }} />
|
||||
<Divider />
|
||||
<FileMenu
|
||||
newModel={properties.newModel}
|
||||
setModel={properties.setModel}
|
||||
onModelUpload={properties.onModelUpload}
|
||||
onDownload={async () => {
|
||||
const model = properties.model;
|
||||
const bytes = model.toBytes();
|
||||
const fileName = model.getName();
|
||||
await downloadModel(bytes, fileName);
|
||||
}}
|
||||
/>
|
||||
<WorkbookTitle
|
||||
name={properties.model.getName()}
|
||||
onNameChange={(name) => {
|
||||
properties.model.setName(name);
|
||||
updateNameSelectedWorkbook(properties.model, name);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={hiddenInputRef}
|
||||
type="text"
|
||||
style={{ position: "absolute", left: -9999, top: -9999 }}
|
||||
/>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
{toast ? (
|
||||
<Toast>
|
||||
<CircleCheck style={{ width: 12 }} />
|
||||
<span style={{ marginLeft: 8, marginRight: 12 }}>
|
||||
URL copied to clipboard
|
||||
</span>
|
||||
</Toast>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<ShareButton
|
||||
onClick={async () => {
|
||||
const model = properties.model;
|
||||
const bytes = model.toBytes();
|
||||
const fileName = model.getName();
|
||||
const hash = await shareModel(bytes, fileName);
|
||||
const value = `${location.origin}/?model=${hash}`;
|
||||
if (hiddenInputRef.current) {
|
||||
hiddenInputRef.current.value = value;
|
||||
hiddenInputRef.current.select();
|
||||
document.execCommand("copy");
|
||||
setToast(true);
|
||||
setTimeout(() => setToast(false), 5000);
|
||||
}
|
||||
console.log(value);
|
||||
}}
|
||||
/>
|
||||
</FileBarWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Toast = styled("div")`
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: #9e9e9e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
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;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid grey;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
154
webapp/src/AppComponents/FileMenu.tsx
Normal file
154
webapp/src/AppComponents/FileMenu.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Menu, MenuItem, Modal } from "@mui/material";
|
||||
import { FileDown, FileUp, Plus } from "lucide-react";
|
||||
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: ArrayBuffer, fileName: string) => Promise<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);
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
<StyledPlus />
|
||||
<MenuItemText>New</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper
|
||||
onClick={() => {
|
||||
setImportMenuOpen(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<StyledFileUp />
|
||||
<MenuItemText>Import</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper>
|
||||
<StyledFileDown />
|
||||
<MenuItemText onClick={props.onDownload}>
|
||||
Download (.xlsx)
|
||||
</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
<MenuDivider />
|
||||
{elements}
|
||||
</Menu>
|
||||
<Modal
|
||||
open={isImportMenuOpen}
|
||||
onClose={() => {
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.style.filter = "";
|
||||
}
|
||||
setImportMenuOpen(false);
|
||||
}}
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description"
|
||||
>
|
||||
<>
|
||||
<UploadFileDialog
|
||||
onClose={() => {
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.style.filter = "";
|
||||
}
|
||||
setImportMenuOpen(false);
|
||||
}}
|
||||
onModelUpload={props.onModelUpload}
|
||||
/>
|
||||
</>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledPlus = styled(Plus)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
const StyledFileDown = styled(FileDown)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
const StyledFileUp = styled(FileUp)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #333333;
|
||||
padding-right: 10px;
|
||||
`;
|
||||
|
||||
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: flex-start;
|
||||
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>
|
||||
);
|
||||
}
|
||||
271
webapp/src/AppComponents/UploadFileDialog.tsx
Normal file
271
webapp/src/AppComponents/UploadFileDialog.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
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: ArrayBuffer, fileName: string) => Promise<void>;
|
||||
}) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { onModelUpload } = properties;
|
||||
|
||||
const handleClose = () => {
|
||||
properties.onClose();
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
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();
|
||||
setHover(false);
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const files = dt.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (file: File) => {
|
||||
setMessage(`Uploading ${file.name}...`);
|
||||
|
||||
// Read the file as ArrayBuffer
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
await onModelUpload(reader.result as ArrayBuffer, file.name);
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
console.log("error", e);
|
||||
setMessage(`${e}`);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
root.style.filter = "blur(4px)";
|
||||
}
|
||||
return (
|
||||
<UploadDialog>
|
||||
<UploadTitle>
|
||||
<span style={{ flexGrow: 2, marginLeft: 12 }}>
|
||||
Import an .xlsx file
|
||||
</span>
|
||||
<Cross
|
||||
style={{ marginRight: 12 }}
|
||||
onClick={handleClose}
|
||||
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>
|
||||
{message === "" ? (
|
||||
<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>
|
||||
) : (
|
||||
<DropZone>
|
||||
<>
|
||||
<div style={{ flexGrow: 2 }} />
|
||||
<div>{message}</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;
|
||||
&:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
`;
|
||||
70
webapp/src/AppComponents/rpc.ts
Normal file
70
webapp/src/AppComponents/rpc.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export async function uploadFile(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
fileName: string,
|
||||
): Promise<Blob> {
|
||||
// Fetch request to upload the file
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
body: arrayBuffer,
|
||||
});
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function get_model(modelHash: string): Promise<Uint8Array> {
|
||||
return new Uint8Array(
|
||||
await (await fetch(`/api/model/${modelHash}`)).arrayBuffer(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadModel(bytes: Uint8Array, fileName: string) {
|
||||
const response = await fetch("/api/download", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
body: bytes,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const blob = await response.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();
|
||||
}
|
||||
|
||||
export async function shareModel(
|
||||
bytes: Uint8Array,
|
||||
fileName: string,
|
||||
): Promise<string> {
|
||||
const response = await fetch("/api/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
body: bytes,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user