From c99aea7b3d8fa8be80ecbdca1c9e12a6153cbe9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Thu, 3 Oct 2024 00:27:29 +0200 Subject: [PATCH] UPDATE: Webapp --- base/src/user_model/common.rs | 10 + bindings/wasm/src/lib.rs | 19 +- webapp/src/App.css | 5 +- webapp/src/App.tsx | 70 ++++- webapp/src/AppComponents/FileBar.tsx | 108 +++++++ webapp/src/AppComponents/FileMenu.tsx | 120 ++++++++ webapp/src/AppComponents/ShareButton.tsx | 28 ++ webapp/src/AppComponents/UploadFileDialog.tsx | 263 ++++++++++++++++++ webapp/src/AppComponents/WorkbookTitle.tsx | 100 +++++++ webapp/src/AppComponents/storage.ts | 112 ++++++++ webapp/src/AppComponents/util.ts | 18 ++ .../WorksheetCanvas/worksheetCanvas.ts | 1 + webapp/src/components/tests/model.test.ts | 2 +- webapp/src/components/util.ts | 2 +- webapp/src/components/workbook.tsx | 1 + webapp/src/icons/index.ts | 3 + webapp/src/icons/orange+black.svg | 8 + webapp/src/main.tsx | 2 +- 18 files changed, 856 insertions(+), 16 deletions(-) create mode 100644 webapp/src/AppComponents/FileBar.tsx create mode 100644 webapp/src/AppComponents/FileMenu.tsx create mode 100644 webapp/src/AppComponents/ShareButton.tsx create mode 100644 webapp/src/AppComponents/UploadFileDialog.tsx create mode 100644 webapp/src/AppComponents/WorkbookTitle.tsx create mode 100644 webapp/src/AppComponents/storage.ts create mode 100644 webapp/src/AppComponents/util.ts create mode 100644 webapp/src/icons/orange+black.svg diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 4c7f3c2..6e46fdc 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -205,6 +205,16 @@ impl UserModel { 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 /// /// See also: diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 9dffc3d..94e9db9 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -37,8 +37,8 @@ pub struct Model { #[wasm_bindgen] impl Model { #[wasm_bindgen(constructor)] - pub fn new(locale: &str, timezone: &str) -> Result { - let model = BaseModel::new_empty("workbook", locale, timezone).map_err(to_js_error)?; + pub fn new(name: &str, locale: &str, timezone: &str) -> Result { + let model = BaseModel::new_empty(name, locale, timezone).map_err(to_js_error)?; Ok(Model { model }) } @@ -482,4 +482,19 @@ impl Model { .map_err(|e| to_js_error(e.to_string()))?; Ok(()) } + + #[wasm_bindgen(js_name = "toBytes")] + pub fn to_bytes(&self) -> Vec { + 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); + } } diff --git a/webapp/src/App.css b/webapp/src/App.css index 7b24a13..890aa46 100644 --- a/webapp/src/App.css +++ b/webapp/src/App.css @@ -1,7 +1,6 @@ #root { position: absolute; inset: 0px; - margin: 10px; - border: 1px solid #aaa; - border-radius: 4px; + margin: 0px; + border: none; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 9fe79b3..8e1338a 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -4,6 +4,14 @@ import "./i18n"; import styled from "@emotion/styled"; import init, { Model } from "@ironcalc/wasm"; import { useEffect, useState } from "react"; +import { FileBar } from "./AppComponents/FileBar"; +import { + createNewModel, + loadModelFromStorageOrCreate, + saveModelToStorage, + saveSelectedModelInStorage, + selectModelFromStorage, +} from "./AppComponents/storage"; import { WorkbookState } from "./components/workbookState"; function App() { @@ -17,20 +25,25 @@ function App() { await init(); const queryString = window.location.search; const urlParams = new URLSearchParams(queryString); - const modelName = urlParams.get("model"); - // If there is a model name ?model=example.ic we try to load it + const modelHash = urlParams.get("model"); + // 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 (modelName) { + if (modelHash) { + // Get a remote model try { 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) { - setModel(new Model("en", "UTC")); + alert("Model not found, or failed to load"); } } else { - setModel(new Model("en", "UTC")); + // try to load from local storage + const newModel = loadModelFromStorageOrCreate(); + setModel(newModel); } setWorkbookState(new WorkbookState()); } @@ -41,12 +54,53 @@ function App() { return 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. // Passing the property down makes sure it is always defined. - return ; + return ( + + { + 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); + } + }} + /> + + + ); } +const Wrapper = styled("div")` + margin: 0px; + padding: 0px; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + position: absolute; +`; + const Loading = styled("div")` height: 100%; display: flex; diff --git a/webapp/src/AppComponents/FileBar.tsx b/webapp/src/AppComponents/FileBar.tsx new file mode 100644 index 0000000..1c3e6c8 --- /dev/null +++ b/webapp/src/AppComponents/FileBar.tsx @@ -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 ( + + + + { + 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); + }); + }} + /> + { + properties.model.setName(name); + updateNameSelectedWorkbook(properties.model, name); + }} + /> + { + 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); + }); + }} + /> + + ); +} + +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; +`; diff --git a/webapp/src/AppComponents/FileMenu.tsx b/webapp/src/AppComponents/FileMenu.tsx new file mode 100644 index 0000000..ac79404 --- /dev/null +++ b/webapp/src/AppComponents/FileMenu.tsx @@ -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(null); + const models = getModelsMetadata(); + const uuids = Object.keys(models); + const selectedUuid = getSelectedUuuid(); + + const elements = []; + for (const uuid of uuids) { + elements.push( + { + props.setModel(uuid); + setMenuOpen(false); + }} + style={{ justifyContent: "flex-start" }} + > + + {uuid === selectedUuid ? "•" : ""} + + {models[uuid]} + , + ); + } + + return ( + <> + setMenuOpen(true)} + ref={anchorElement} + > + File + + setMenuOpen(false)} + anchorEl={anchorElement.current} + // anchorOrigin={properties.anchorOrigin} + > + + New + + { + setImportMenuOpen(true); + setMenuOpen(false); + }} + > + Import + + + + Download (.xlsx) + + + + {elements} + + { + setImportMenuOpen(false); + }} + aria-labelledby="modal-modal-title" + aria-describedby="modal-modal-description" + > + <> + setImportMenuOpen(false)} + onModelUpload={props.onModelUpload} + /> + + + + ); +} + +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; + } +`; diff --git a/webapp/src/AppComponents/ShareButton.tsx b/webapp/src/AppComponents/ShareButton.tsx new file mode 100644 index 0000000..7d24c8a --- /dev/null +++ b/webapp/src/AppComponents/ShareButton.tsx @@ -0,0 +1,28 @@ +import { Share2 } from "lucide-react"; + +export function ShareButton(properties: { onClick: () => void }) { + const { onClick } = properties; + return ( +
{}} + 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", + }} + > + + Share +
+ ); +} diff --git a/webapp/src/AppComponents/UploadFileDialog.tsx b/webapp/src/AppComponents/UploadFileDialog.tsx new file mode 100644 index 0000000..8d49115 --- /dev/null +++ b/webapp/src/AppComponents/UploadFileDialog.tsx @@ -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(null); + + const { onClose, onModelUpload } = properties; + const handleDragEnter = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + console.log("Enter"); + setHover(true); + }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + // setHover(true); + }; + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + console.log("Leave"); + setHover(false); + }; + + const handleDrop = (event: DragEvent) => { + 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 ( + + + Import a .xlsx File + {}} + > + + Close + + + + + + + {!hover ? ( + <> +
+
+ +
+
+ + Drag and drop a file here or{" "} + + { + const files = event.target.files; + if (files) { + for (const file of files) { + handleFileUpload(file); + } + } + }} + /> + { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }} + > + click to browse + +
+
+ + ) : ( + <> +
+
Drop file here
+
+ + )} + + + + + Learn more about importing files into IronCalc + + + ); +} + +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; +`; diff --git a/webapp/src/AppComponents/WorkbookTitle.tsx b/webapp/src/AppComponents/WorkbookTitle.tsx new file mode 100644 index 0000000..9b82b9c --- /dev/null +++ b/webapp/src/AppComponents/WorkbookTitle.tsx @@ -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(null); + + const handleChange = (event: ChangeEvent) => { + 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 ( +
+ { + props.onNameChange(event.target.value); + }} + style={{ width: width }} + spellCheck="false" + > + {value} + +
+ {value} +
+
+ ); +} + +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; + `; diff --git a/webapp/src/AppComponents/storage.ts b/webapp/src/AppComponents/storage.ts new file mode 100644 index 0000000..1778496 --- /dev/null +++ b/webapp/src/AppComponents/storage.ts @@ -0,0 +1,112 @@ +import { Model } from "@ironcalc/wasm"; +import { base64ToBytes, bytesToBase64 } from "./util"; + +const MAX_WORKBOOKS = 50; + +type ModelsMetadata = Record; + +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"); +} diff --git a/webapp/src/AppComponents/util.ts b/webapp/src/AppComponents/util.ts new file mode 100644 index 0000000..ac74ad4 --- /dev/null +++ b/webapp/src/AppComponents/util.ts @@ -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); +} diff --git a/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts b/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts index ad86fbf..9c229ca 100644 --- a/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -1304,6 +1304,7 @@ export default class WorksheetCanvas { ctx.setLineDash([]); } + renderSheet(): void { console.time("renderSheet"); this._renderSheet(); diff --git a/webapp/src/components/tests/model.test.ts b/webapp/src/components/tests/model.test.ts index 80c21c9..0d030c8 100644 --- a/webapp/src/components/tests/model.test.ts +++ b/webapp/src/components/tests/model.test.ts @@ -7,7 +7,7 @@ import { expect, test } from "vitest"; test("simple calculation", async () => { const buffer = await readFile("node_modules/@ironcalc/wasm/wasm_bg.wasm"); initSync(buffer); - const model = new Model("en", "UTC"); + const model = new Model("workbook", "en", "UTC"); model.setUserInput(0, 1, 1, "=21*2"); expect(model.getFormattedCellValue(0, 1, 1)).toBe("42"); }); diff --git a/webapp/src/components/util.ts b/webapp/src/components/util.ts index 909acbb..43c3f26 100644 --- a/webapp/src/components/util.ts +++ b/webapp/src/components/util.ts @@ -53,7 +53,7 @@ export function rangeToStr( referenceName: string, ): string { const { sheet, rowStart, rowEnd, columnStart, columnEnd } = range; - const sheetName = sheet === referenceSheet ? "" : `${referenceName}!`; + const sheetName = sheet === referenceSheet ? "" : `'${referenceName}'!`; if (rowStart === rowEnd && columnStart === columnEnd) { return `${sheetName}${columnNameFromNumber(columnStart)}${rowStart}`; } diff --git a/webapp/src/components/workbook.tsx b/webapp/src/components/workbook.tsx index d408cac..f1ca20f 100644 --- a/webapp/src/components/workbook.tsx +++ b/webapp/src/components/workbook.tsx @@ -426,6 +426,7 @@ const Container = styled("div")` display: flex; flex-direction: column; height: 100%; + position: relative; font-family: ${({ theme }) => theme.typography.fontFamily}; &:focus { diff --git a/webapp/src/icons/index.ts b/webapp/src/icons/index.ts index 5f2dd16..f4565fe 100644 --- a/webapp/src/icons/index.ts +++ b/webapp/src/icons/index.ts @@ -20,6 +20,8 @@ import InsertColumnRightIcon from "./insert-column-right.svg?react"; import InsertRowAboveIcon from "./insert-row-above.svg?react"; import InsertRowBelow from "./insert-row-below.svg?react"; +import IronCalcLogo from "./orange+black.svg?react"; + import Fx from "./fx.svg?react"; export { @@ -42,5 +44,6 @@ export { InsertColumnRightIcon, InsertRowAboveIcon, InsertRowBelow, + IronCalcLogo, Fx, }; diff --git a/webapp/src/icons/orange+black.svg b/webapp/src/icons/orange+black.svg new file mode 100644 index 0000000..aa13f62 --- /dev/null +++ b/webapp/src/icons/orange+black.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index edb6ee8..3cf6b6c 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -1,8 +1,8 @@ -import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; import ThemeProvider from "@mui/material/styles/ThemeProvider"; +import React from "react"; import { theme } from "./theme.ts"; // biome-ignore lint: we know the 'root' element exists.