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/index.html b/webapp/index.html index fde05c2..7ce0e72 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -12,5 +12,6 @@
+ diff --git a/webapp/src/App.css b/webapp/src/App.css index 4e77e42..890aa46 100644 --- a/webapp/src/App.css +++ b/webapp/src/App.css @@ -1,6 +1,6 @@ #root { position: absolute; - inset: 10px; - border: 1px solid #aaa; - border-radius: 4px; + inset: 0px; + margin: 0px; + border: none; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index bc19969..ec8c0eb 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -4,6 +4,15 @@ import "./i18n"; import styled from "@emotion/styled"; import init, { Model } from "@ironcalc/wasm"; import { useEffect, useState } from "react"; +import { FileBar } from "./AppComponents/FileBar"; +import { get_model, uploadFile } from "./AppComponents/rpc"; +import { + createNewModel, + loadModelFromStorageOrCreate, + saveModelToStorage, + saveSelectedModelInStorage, + selectModelFromStorage, +} from "./AppComponents/storage"; import { WorkbookState } from "./components/workbookState"; function App() { @@ -11,25 +20,29 @@ function App() { const [workbookState, setWorkbookState] = useState( null, ); + useEffect(() => { async function start() { 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(), - ); - setModel(Model.from_bytes(model_bytes)); + const model_bytes = await get_model(modelHash); + 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()); } @@ -40,11 +53,55 @@ 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 blob = await uploadFile(arrayBuffer, fileName); + + 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..99d48e3 --- /dev/null +++ b/webapp/src/AppComponents/FileBar.tsx @@ -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; +}) { + const hiddenInputRef = useRef(null); + const [toast, setToast] = useState(false); + return ( + + + + { + const model = properties.model; + const bytes = model.toBytes(); + const fileName = model.getName(); + await downloadModel(bytes, fileName); + }} + /> + { + properties.model.setName(name); + updateNameSelectedWorkbook(properties.model, name); + }} + /> + +
+ {toast ? ( + + + + URL copied to clipboard + + + ) : ( + "" + )} +
+ { + 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); + }} + /> +
+ ); +} + +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; +`; diff --git a/webapp/src/AppComponents/FileMenu.tsx b/webapp/src/AppComponents/FileMenu.tsx new file mode 100644 index 0000000..6879404 --- /dev/null +++ b/webapp/src/AppComponents/FileMenu.tsx @@ -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; +}) { + 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); + }} + > + + {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} + + { + const root = document.getElementById("root"); + if (root) { + root.style.filter = ""; + } + setImportMenuOpen(false); + }} + aria-labelledby="modal-modal-title" + aria-describedby="modal-modal-description" + > + <> + { + const root = document.getElementById("root"); + if (root) { + root.style.filter = ""; + } + setImportMenuOpen(false); + }} + onModelUpload={props.onModelUpload} + /> + + + + ); +} + +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; + } +`; diff --git a/webapp/src/AppComponents/ShareButton.tsx b/webapp/src/AppComponents/ShareButton.tsx new file mode 100644 index 0000000..56482ea --- /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..4ab5d29 --- /dev/null +++ b/webapp/src/AppComponents/UploadFileDialog.tsx @@ -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; +}) { + const [hover, setHover] = useState(false); + const [message, setMessage] = useState(""); + const fileInputRef = useRef(null); + + const { onModelUpload } = properties; + + const handleClose = () => { + properties.onClose(); + }; + + const handleDragEnter = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setHover(true); + }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setHover(true); + }; + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setHover(false); + }; + + const handleDrop = (event: DragEvent) => { + 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 ( + + + + Import an .xlsx file + + {}} + > + + Close + + + + + + {message === "" ? ( + + {!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
+
+ + )} + + ) : ( + + <> +
+
{message}
+
+ + + )} + + + + 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; + &: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; +`; 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/rpc.ts b/webapp/src/AppComponents/rpc.ts new file mode 100644 index 0000000..5f988c2 --- /dev/null +++ b/webapp/src/AppComponents/rpc.ts @@ -0,0 +1,70 @@ +export async function uploadFile( + arrayBuffer: ArrayBuffer, + fileName: string, +): Promise { + // 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 { + 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 { + 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(); +} 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 4c5d00b..9c229ca 100644 --- a/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts +++ b/webapp/src/components/WorksheetCanvas/worksheetCanvas.ts @@ -30,6 +30,7 @@ export interface CanvasSettings { columnGuide: HTMLDivElement; rowGuide: HTMLDivElement; columnHeaders: HTMLDivElement; + editor: HTMLDivElement; }; onColumnWidthChanges: (sheet: number, column: number, width: 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 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 { sheetWidth: number; @@ -61,6 +79,8 @@ export default class WorksheetCanvas { canvas: HTMLCanvasElement; + editor: HTMLDivElement; + areaOutline: HTMLDivElement; cellOutline: HTMLDivElement; @@ -92,6 +112,7 @@ export default class WorksheetCanvas { this.height = options.height; this.ctx = this.setContext(); this.workbookState = options.workbookState; + this.editor = options.elements.editor; this.cellOutline = options.elements.cellOutline; this.cellOutlineHandle = options.elements.cellOutlineHandle; @@ -1092,7 +1113,7 @@ export default class WorksheetCanvas { this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding; const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding; - const { cellOutline, areaOutline, cellOutlineHandle } = this; + const { cellOutline, editor, areaOutline, cellOutlineHandle } = this; const cellEditing = null; cellOutline.style.visibility = "visible"; @@ -1105,6 +1126,19 @@ export default class WorksheetCanvas { 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 cellOutline.style.left = `${x - padding}px`; cellOutline.style.top = `${y - padding}px`; @@ -1214,6 +1248,63 @@ export default class WorksheetCanvas { 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 { console.time("renderSheet"); this._renderSheet(); @@ -1352,5 +1443,6 @@ export default class WorksheetCanvas { this.drawCellOutline(); this.drawExtendToArea(); + this.drawActiveRanges(topLeftCell, bottomRightCell); } } diff --git a/webapp/src/components/editor/editor.tsx b/webapp/src/components/editor/editor.tsx new file mode 100644 index 0000000..a690406 --- /dev/null +++ b/webapp/src/components/editor/editor.tsx @@ -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(null); + const maskRef = useRef(null); + const textareaRef = useRef(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 ( +
+
+
{styledFormula}
+
+