diff --git a/src/frontend/apps/impress/next.config.js b/src/frontend/apps/impress/next.config.js index 16d2bbaae..eb442868b 100644 --- a/src/frontend/apps/impress/next.config.js +++ b/src/frontend/apps/impress/next.config.js @@ -61,6 +61,8 @@ const nextConfig = { // Modify the file loader rule to ignore *.svg, since we have it handled now. fileLoaderRule.exclude = /\.svg$/i; + config.resolve.alias['@ironcalc/wasm'] = "@/features/docs/doc-editor/components/ironcalc/wasm"; + return config; }, }; diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 8b63f4a2d..f374a8f24 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -18,8 +18,10 @@ "@blocknote/core": "*", "@blocknote/mantine": "*", "@blocknote/react": "*", + "@emotion/styled": "11.11.5", "@gouvfr-lasuite/integration": "1.0.2", "@hocuspocus/provider": "2.14.0", + "@mui/material": "5.15.21", "@openfun/cunningham-react": "2.9.4", "@sentry/nextjs": "8.40.0", "@tanstack/react-query": "5.61.3", @@ -28,10 +30,12 @@ "i18next-browser-languagedetector": "8.0.0", "idb": "8.0.0", "lodash": "4.17.21", + "lucide-react": "0.427.0", "luxon": "3.5.0", "next": "15.0.3", "react": "*", "react-aria-components": "1.5.0", + "react-colorful": "5.6.1", "react-dom": "*", "react-i18next": "15.1.1", "react-select": "5.8.3", diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index 50b2b73aa..fbf4f4cea 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -14,6 +14,7 @@ import { useResponsiveStore } from '@/stores'; import { useHeadingStore } from '../stores'; import { BlockNoteEditor } from './BlockNoteEditor'; +import { IronCalcEditor } from './IronCalcEditor'; import { IconOpenPanelEditor, PanelEditor } from './PanelEditor'; interface DocEditorProps { @@ -30,6 +31,8 @@ export const DocEditor = ({ doc }: DocEditorProps) => { const isVersion = versionId && typeof versionId === 'string'; + const isSpreadsheet = true; //doc.content_type === 'spreadsheet'; + const { colorsTokens } = useCunninghamTheme(); const { providers } = useDocStore(); @@ -65,12 +68,14 @@ export const DocEditor = ({ doc }: DocEditorProps) => { $position="relative" > - {isVersion ? ( + {isSpreadsheet ? ( + + ) : isVersion ? ( ) : ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/IronCalcEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/IronCalcEditor.tsx new file mode 100644 index 000000000..1c54691da --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/IronCalcEditor.tsx @@ -0,0 +1,70 @@ +import dynamic from 'next/dynamic'; +import { useEffect, useState } from 'react'; +import init, { Model } from "@ironcalc/wasm"; +import { HocuspocusProvider } from '@hocuspocus/provider'; +import { Doc } from '@/features/docs/doc-management'; +import { WorkbookState } from "./ironcalc/components/workbookState"; +import { base64ToBytes, bytesToBase64 } from "./ironcalc/AppComponents/util"; +const IronCalcWorkbook = dynamic( + () => import("./ironcalc/components/workbook"), + { ssr: false } +); + +interface IronCalcEditorProps { + doc: Doc; + provider: HocuspocusProvider; + storeId: string; +} + +export function IronCalcEditor({doc, storeId, provider}: IronCalcEditorProps) { + const [model, setModel] = useState(null); + const [workbookState, setWorkbookState] = useState(null); + + const isVersion = doc.id !== storeId; + const readOnly = !doc.abilities.partial_update || isVersion; + + // Listen for model changes + useEffect(() => { + if (!model || readOnly) return; + + const interval = setInterval(() => { + const queue = model.flushSendQueue(); + if (queue.length !== 1) { + // Convert model to base64 string + const modelContent = bytesToBase64(model.toBytes()); + + // TODO: Save to server + console.log("Doc modified. new base64: ", modelContent); + } + }, 1000); + + return () => clearInterval(interval); + }, [model, doc.id, readOnly]); + + useEffect(() => { + init().then(() => { + setWorkbookState(new WorkbookState()); + + // TODO: Load existing content from server + if (doc.content && false) { + try { + const bytes = base64ToBytes(doc.content); + return setModel(Model.from_bytes(bytes)); + } catch (e) { + console.error('Failed to load existing content:', e); + } + } + + // If no content or failed to load, create new model + setModel(new Model("Workbook1", "en", "UTC")); + }); + }, [doc.content]); + + if (!model || !workbookState) { + return
Loading...
; + } + + return
+ +
; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/App.css b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/App.css new file mode 100644 index 000000000..98ad6767f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/App.css @@ -0,0 +1,10 @@ +#root { + position: absolute; + inset: 0px; + margin: 0px; + border: none; +} +html, +body { + overscroll-behavior: none; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/App.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/App.tsx new file mode 100644 index 000000000..c8069f9c5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/App.tsx @@ -0,0 +1,143 @@ +import "./App.css"; +import Workbook from "./components/workbook"; +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_documentation_model, + get_model, + uploadFile, +} from "./AppComponents/rpc"; +import { + createNewModel, + deleteSelectedModel, + loadModelFromStorageOrCreate, + saveModelToStorage, + saveSelectedModelInStorage, + selectModelFromStorage, +} from "./AppComponents/storage"; +import { WorkbookState } from "./components/workbookState"; +import { IronCalcIcon } from "./icons"; + +function App() { + const [model, setModel] = useState(null); + const [workbookState, setWorkbookState] = useState( + null, + ); + + useEffect(() => { + async function start() { + await init(); + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + const modelHash = urlParams.get("model"); + const filename = urlParams.get("filename"); + // 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 (modelHash) { + // Get a remote model + try { + const model_bytes = await get_model(modelHash); + const importedModel = Model.from_bytes(model_bytes); + localStorage.removeItem("selected"); + setModel(importedModel); + } catch (e) { + alert("Model not found, or failed to load"); + } + } + if (filename) { + try { + const model_bytes = await get_documentation_model(filename); + const importedModel = Model.from_bytes(model_bytes); + localStorage.removeItem("selected"); + setModel(importedModel); + } catch (e) { + alert("Model not found, or failed to load"); + } + } else { + // try to load from local storage + const newModel = loadModelFromStorageOrCreate(); + setModel(newModel); + } + setWorkbookState(new WorkbookState()); + } + start(); + }, []); + + if (!model || !workbookState) { + return ( + + +
Loading IronCalc
+
+ ); + } + + // 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 ( + + { + 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); + } + }} + onDelete={() => { + const newModel = deleteSelectedModel(); + 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; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: "Inter"; + font-size: 14px; +`; + +export default App; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/FileBar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/FileBar.tsx new file mode 100644 index 000000000..3c9f2eb47 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/FileBar.tsx @@ -0,0 +1,124 @@ +import styled from "@emotion/styled"; +import type { Model } from "@ironcalc/wasm"; +import { CircleCheck } from "lucide-react"; +import { useRef, useState } from "react"; +import { IronCalcIcon, 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; + onDelete: () => void; +}) { + 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); + }} + onDelete={properties.onDelete} + /> + { + 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 StyledDesktopLogo = styled(IronCalcLogo)` + width: 120px; + margin-left: 10px; + @media (max-width: 769px) { + display: none; + } +`; + +const StyledIronCalcIcon = styled(IronCalcIcon)` + width: 36px; + margin-left: 10px; + @media (min-width: 769px) { + display: none; + } +`; + +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 #e0e0e0; + position: relative; + justify-content: space-between; +`; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/FileMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/FileMenu.tsx new file mode 100644 index 000000000..d390d5761 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/FileMenu.tsx @@ -0,0 +1,184 @@ +import styled from "@emotion/styled"; +import { Menu, MenuItem, Modal } from "@mui/material"; +import { FileDown, FileUp, Plus, Trash2 } from "lucide-react"; +import { useRef, useState } from "react"; +import { UploadFileDialog } from "./UploadFileDialog"; +import { getModelsMetadata, getSelectedUuid } from "./storage"; + +export function FileMenu(props: { + newModel: () => void; + setModel: (key: string) => void; + onDownload: () => void; + onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise; + onDelete: () => 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 = getSelectedUuid(); + + 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) + + + + + { + props.onDelete(); + setMenuOpen(false); + }} + > + Delete workbook + + + + {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 StyledTrash = styled(Trash2)` + width: 16px; + height: 16px; + color: #333333; + padding-right: 10px; +`; + +const MenuDivider = styled("div")` + width: 80%; + margin: auto; + margin-top: 8px; + margin-bottom: 8px; + border-top: 1px solid #e0e0e0; +`; + +const MenuItemText = styled("div")` + color: #000; + font-size: 12px; +`; + +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; + cursor: pointer; + &:hover { + background-color: #f2f2f2; + } +`; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/ShareButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/ShareButton.tsx new file mode 100644 index 000000000..a14fa38f4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/ShareButton.tsx @@ -0,0 +1,30 @@ +import styled from "@emotion/styled"; +import { Share2 } from "lucide-react"; + +export function ShareButton(properties: { onClick: () => void }) { + const { onClick } = properties; + return ( + {}}> + + Share + + ); +} + +const Wrapper = styled("div")` + cursor: pointer; + color: #ffffff; + background: #f2994a; + padding: 0px 10px; + height: 36px; + line-height: 36px; + border-radius: 4px; + margin-right: 10px; + display: flex; + align-items: center; + font-family: "Inter"; + font-size: 14px; + &:hover { + background: #d68742; + } +`; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/UploadFileDialog.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/UploadFileDialog.tsx new file mode 100644 index 000000000..4ab5d2963 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/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/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/WorkbookTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/WorkbookTitle.tsx new file mode 100644 index 000000000..1836c9da7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/WorkbookTitle.tsx @@ -0,0 +1,104 @@ +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; + max-width: 520px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/rpc.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/rpc.ts new file mode 100644 index 000000000..b301d93f5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/rpc.ts @@ -0,0 +1,78 @@ +export async function uploadFile( + arrayBuffer: ArrayBuffer, + fileName: string, +): Promise { + // Fetch request to upload the file + const response = await fetch(`/api/upload/${fileName}`, { + 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 get_documentation_model( + filename: string, +): Promise { + return new Uint8Array( + await (await fetch(`/models/${filename}.ic`)).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/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/storage.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/storage.ts new file mode 100644 index 000000000..c6db3f04b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/storage.ts @@ -0,0 +1,129 @@ +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 | null { + localStorage.setItem("selected", uuid); + const modelBytesString = localStorage.getItem(uuid); + if (modelBytesString) { + return Model.from_bytes(base64ToBytes(modelBytesString)); + } + return null; +} + +export function getSelectedUuid(): string | null { + return localStorage.getItem("selected"); +} + +export function deleteSelectedModel(): Model | null { + const uuid = localStorage.getItem("selected"); + if (!uuid) { + return null; + } + localStorage.removeItem(uuid); + const metadata = getModelsMetadata(); + delete metadata[uuid]; + localStorage.setItem("models", JSON.stringify(metadata)); + const uuids = Object.keys(metadata); + if (uuids.length === 0) { + return createNewModel(); + } + return selectModelFromStorage(uuids[0]); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/util.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/AppComponents/util.ts new file mode 100644 index 000000000..ac74ad446 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/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/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/README.md b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/README.md new file mode 100644 index 000000000..dc8fa8a89 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/README.md @@ -0,0 +1,12 @@ +# Keyboard and mouse events architecture + +This document describes the architecture of the keyboard navigation and mouse events in IronCalc Web + +There are two modes for mouse events: + +* Normal mode: clicking a cell selects it, clicking on a sheet opens it +* Browse mode: clicking on a cell updates the formula, etc + +While in browse mode some mouse events might end the browse mode + +We follow Excel's way of navigating a spreadsheet \ No newline at end of file diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/WorksheetCanvas/constants.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/WorksheetCanvas/constants.ts new file mode 100644 index 000000000..a8b2fa37d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/WorksheetCanvas/constants.ts @@ -0,0 +1,21 @@ +export const headerCornerBackground = "#FFF"; +export const headerTextColor = "#333"; +export const headerBackground = "#FFF"; +export const headerGlobalSelectorColor = "#EAECF4"; +export const headerSelectedBackground = "#EEEEEE"; +export const headerFullSelectedBackground = "#D3D6E9"; +export const headerSelectedColor = "#333"; +export const headerBorderColor = "#DEE0EF"; + +export const gridColor = "#E0E0E0"; +export const gridSeparatorColor = "#D3D6E9"; +export const defaultTextColor = "#2E414D"; + +export const outlineColor = "#F2994A"; +export const outlineBackgroundColor = "#F2994A1A"; + +export const LAST_COLUMN = 16_384; +export const LAST_ROW = 1_048_576; + +export const ROW_HEIGH_SCALE = 1; +export const COLUMN_WIDTH_SCALE = 1; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/WorksheetCanvas/worksheetCanvas.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/WorksheetCanvas/worksheetCanvas.ts new file mode 100644 index 000000000..d66b63121 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/WorksheetCanvas/worksheetCanvas.ts @@ -0,0 +1,1469 @@ +import type { Model } from "@ironcalc/wasm"; +import { columnNameFromNumber } from "@ironcalc/wasm"; +import { getColor } from "../editor/util"; +import type { Cell } from "../types"; +import type { WorkbookState } from "../workbookState"; +import { + COLUMN_WIDTH_SCALE, + LAST_COLUMN, + LAST_ROW, + ROW_HEIGH_SCALE, + defaultTextColor, + gridColor, + gridSeparatorColor, + headerBackground, + headerBorderColor, + headerSelectedBackground, + headerSelectedColor, + headerTextColor, + outlineColor, +} from "./constants"; + +export interface CanvasSettings { + model: Model; + width: number; + height: number; + workbookState: WorkbookState; + elements: { + canvas: HTMLCanvasElement; + cellOutline: HTMLDivElement; + areaOutline: HTMLDivElement; + cellOutlineHandle: HTMLDivElement; + extendToOutline: HTMLDivElement; + columnGuide: HTMLDivElement; + rowGuide: HTMLDivElement; + columnHeaders: HTMLDivElement; + editor: HTMLDivElement; + }; + onColumnWidthChanges: (sheet: number, column: number, width: number) => void; + onRowHeightChanges: (sheet: number, row: number, height: number) => void; +} + +export const fonts = { + regular: 'Inter, "Adjusted Arial Fallback", sans-serif', + mono: '"Fira Mono", "Adjusted Courier New Fallback", serif', +}; + +export const headerRowHeight = 28; +export const headerColumnWidth = 30; +export const devicePixelRatio = window.devicePixelRatio || 1; + +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; + + sheetHeight: number; + + width: number; + + height: number; + + ctx: CanvasRenderingContext2D; + + canvas: HTMLCanvasElement; + + editor: HTMLDivElement; + + areaOutline: HTMLDivElement; + + cellOutline: HTMLDivElement; + + cellOutlineHandle: HTMLDivElement; + + extendToOutline: HTMLDivElement; + + workbookState: WorkbookState; + + model: Model; + + rowGuide: HTMLDivElement; + + columnHeaders: HTMLDivElement; + + columnGuide: HTMLDivElement; + + onColumnWidthChanges: (sheet: number, column: number, width: number) => void; + + onRowHeightChanges: (sheet: number, row: number, height: number) => void; + + constructor(options: CanvasSettings) { + this.model = options.model; + this.sheetWidth = 0; + this.sheetHeight = 0; + this.canvas = options.elements.canvas; + this.width = options.width; + 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; + this.areaOutline = options.elements.areaOutline; + this.extendToOutline = options.elements.extendToOutline; + this.rowGuide = options.elements.rowGuide; + this.columnGuide = options.elements.columnGuide; + this.columnHeaders = options.elements.columnHeaders; + + this.onColumnWidthChanges = options.onColumnWidthChanges; + this.onRowHeightChanges = options.onRowHeightChanges; + this.resetHeaders(); + } + + setScrollPosition(scrollPosition: { left: number; top: number }): void { + // We ony scroll whole rows and whole columns + // left, top are maximized with constraints: + // 1. left <= scrollPosition.left + // 2. top <= scrollPosition.top + // 3. (left, top) are the absolute coordinates of a cell + const { column } = this.getBoundedColumn(scrollPosition.left); + const { row } = this.getBoundedRow(scrollPosition.top); + this.model.setTopLeftVisibleCell(row, column); + } + + resetHeaders(): void { + for (const handle of this.columnHeaders.querySelectorAll( + ".column-resize-handle", + )) { + handle.remove(); + } + for (const columnSeparator of this.columnHeaders.querySelectorAll( + ".frozen-column-separator", + )) { + columnSeparator.remove(); + } + for (const header of this.columnHeaders.children) { + (header as HTMLDivElement).classList.add("column-header"); + } + } + + setContext(): CanvasRenderingContext2D { + const { canvas, width, height } = this; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error( + "This browser does not support 2-dimensional canvas rendering contexts.", + ); + } + // If the devicePixelRatio is 2 then the canvas is twice as large to avoid blurring. + canvas.width = width * devicePixelRatio; + canvas.height = height * devicePixelRatio; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + context.scale(devicePixelRatio, devicePixelRatio); + return context; + } + + setSize(size: { width: number; height: number }): void { + this.width = size.width; + this.height = size.height; + this.ctx = this.setContext(); + } + + /** + * This is the height of the frozen rows including the width of the separator + * It returns 0 if the are no frozen rows + */ + getFrozenRowsHeight(): number { + const frozenRows = this.model.getFrozenRowsCount( + this.model.getSelectedSheet(), + ); + if (frozenRows === 0) { + return 0; + } + let frozenRowsHeight = 0; + for (let row = 1; row <= frozenRows; row += 1) { + frozenRowsHeight += this.getRowHeight(this.model.getSelectedSheet(), row); + } + return frozenRowsHeight + frozenSeparatorWidth; + } + + /** + * This is the width of the frozen columns including the width of the separator + * It returns 0 if the are no frozen columns + */ + getFrozenColumnsWidth(): number { + const frozenColumns = this.model.getFrozenColumnsCount( + this.model.getSelectedSheet(), + ); + if (frozenColumns === 0) { + return 0; + } + let frozenColumnsWidth = 0; + for (let column = 1; column <= frozenColumns; column += 1) { + frozenColumnsWidth += this.getColumnWidth( + this.model.getSelectedSheet(), + column, + ); + } + return frozenColumnsWidth + frozenSeparatorWidth; + } + + // Get the visible cells (aside from the frozen rows and columns) + getVisibleCells(): { + topLeftCell: Cell; + bottomRightCell: Cell; + } { + const view = this.model.getSelectedView(); + const selectedSheet = view.sheet; + const frozenRows = this.model.getFrozenRowsCount(selectedSheet); + const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet); + const rowTop = Math.max(frozenRows + 1, view.top_row); + let rowBottom = rowTop; + const columnLeft = Math.max(frozenColumns + 1, view.left_column); + let columnRight = columnLeft; + const frozenColumnsWidth = this.getFrozenColumnsWidth(); + const frozenRowsHeight = this.getFrozenRowsHeight(); + let y = headerRowHeight + frozenRowsHeight; + for (let row = rowTop; row <= LAST_ROW; row += 1) { + const rowHeight = this.getRowHeight(selectedSheet, row); + if (y >= this.height - rowHeight || row === LAST_ROW) { + rowBottom = row; + break; + } + y += rowHeight; + } + + let x = headerColumnWidth + frozenColumnsWidth; + for (let column = columnLeft; column <= LAST_COLUMN; column += 1) { + const columnWidth = this.getColumnWidth(selectedSheet, column); + if (x >= this.width - columnWidth || column === LAST_COLUMN) { + columnRight = column; + break; + } + x += columnWidth; + } + + const cells = { + topLeftCell: { row: rowTop, column: columnLeft }, + bottomRightCell: { row: rowBottom, column: columnRight }, + }; + + return cells; + } + + /** + * Returns the {row, top} of the row whose upper y coordinate (top) is maximum and less or equal than maxTop + * Both top and maxTop are absolute coordinates + */ + getBoundedRow(maxTop: number): { row: number; top: number } { + const selectedSheet = this.model.getSelectedSheet(); + let top = 0; + let row = 1 + this.model.getFrozenRowsCount(selectedSheet); + while (row <= LAST_ROW && top <= maxTop) { + const height = this.getRowHeight(selectedSheet, row); + if (top + height <= maxTop) { + top += height; + } else { + break; + } + row += 1; + } + return { row, top }; + } + + private getBoundedColumn(maxLeft: number): { column: number; left: number } { + let left = 0; + const selectedSheet = this.model.getSelectedSheet(); + let column = 1 + this.model.getFrozenColumnsCount(selectedSheet); + while (left <= maxLeft && column <= LAST_COLUMN) { + const width = this.getColumnWidth(selectedSheet, column); + if (width + left <= maxLeft) { + left += width; + } else { + break; + } + column += 1; + } + return { column, left }; + } + + /** + * Returns the minimum we can scroll to the left so that + * targetColumn is fully visible. + * Returns the the first visible column and the scrollLeft position + */ + getMinScrollLeft(targetColumn: number): number { + const columnStart = + 1 + this.model.getFrozenColumnsCount(this.model.getSelectedSheet()); + /** Distance from the first non frozen cell to the right border of column*/ + let distance = 0; + for (let column = columnStart; column <= targetColumn; column += 1) { + const width = this.getColumnWidth(this.model.getSelectedSheet(), column); + distance += width; + } + /** Minimum we need to scroll so that `column` is visible */ + const minLeft = + distance - this.width + this.getFrozenColumnsWidth() + headerColumnWidth; + + // Because scrolling is quantified, we only scroll whole columns, + // we need to find the minimum quantum that is larger than minLeft + let left = 0; + for (let column = columnStart; column <= LAST_COLUMN; column += 1) { + const width = this.getColumnWidth(this.model.getSelectedSheet(), column); + if (left < minLeft) { + left += width; + } else { + break; + } + } + return left; + } + + private renderCell( + row: number, + column: number, + x: number, + y: number, + width: number, + height: number, + ): void { + const selectedSheet = this.model.getSelectedSheet(); + const style = this.model.getCellStyle(selectedSheet, row, column); + + let backgroundColor = "#FFFFFF"; + if (style.fill.fg_color) { + backgroundColor = style.fill.fg_color; + } + const cellGridColor = this.model.getShowGridLines(selectedSheet) + ? gridColor + : backgroundColor; + + const fontSize = 13; + let font = `${fontSize}px ${defaultCellFontFamily}`; + let textColor = defaultTextColor; + if (style.font) { + textColor = style.font.color; + font = style.font.b ? `bold ${font}` : `400 ${font}`; + if (style.font.i) { + font = `italic ${font}`; + } + } + let horizontalAlign = "general"; + if (style.alignment?.horizontal) { + horizontalAlign = style.alignment.horizontal; + } + let verticalAlign = "bottom"; + if (style.alignment?.vertical) { + verticalAlign = style.alignment.vertical; + } + + const context = this.ctx; + context.font = font; + context.fillStyle = backgroundColor; + context.fillRect(x, y, width, height); + context.fillStyle = textColor; + + // Let's do the border + // Algorithm: + // * we use the border if present + // * otherwise we use the border of the adjacent cell + // * otherwise we use the color of the background + // * otherwise we use the background color of the adjacent cell + // * if everything else fails we use the default grid color + // We only set the left and top borders (right and bottom are set later) + const border = style.border; + + let borderLeftColor = cellGridColor; + let borderLeftWidth = 1; + if (border.left) { + borderLeftColor = border.left.color; + switch (border.left.style) { + case "thin": + break; + case "medium": + borderLeftWidth = 2; + break; + case "thick": + borderLeftWidth = 3; + } + } else { + const leftStyle = this.model.getCellStyle(selectedSheet, row, column - 1); + if (leftStyle.border.right) { + borderLeftColor = leftStyle.border.right.color; + switch (leftStyle.border.right.style) { + case "thin": + break; + case "medium": + borderLeftWidth = 2; + break; + case "thick": + borderLeftWidth = 3; + } + } else if (style.fill.fg_color) { + borderLeftColor = style.fill.fg_color; + } else if (leftStyle.fill.fg_color) { + borderLeftColor = leftStyle.fill.fg_color; + } + } + context.beginPath(); + context.strokeStyle = borderLeftColor; + context.lineWidth = borderLeftWidth; + context.moveTo(x, y); + context.lineTo(x, y + height); + context.stroke(); + + let borderTopColor = cellGridColor; + let borderTopWidth = 1; + if (border.top) { + borderTopColor = border.top.color; + switch (border.top.style) { + case "thin": + break; + case "medium": + borderTopWidth = 2; + break; + case "thick": + borderTopWidth = 3; + } + } else { + const topStyle = this.model.getCellStyle(selectedSheet, row - 1, column); + if (topStyle.border.bottom) { + borderTopColor = topStyle.border.bottom.color; + switch (topStyle.border.bottom.style) { + case "thin": + break; + case "medium": + borderTopWidth = 2; + break; + case "thick": + borderTopWidth = 3; + } + } else if (style.fill.fg_color) { + borderTopColor = style.fill.fg_color; + } else if (topStyle.fill.fg_color) { + borderTopColor = topStyle.fill.fg_color; + } + } + context.beginPath(); + context.strokeStyle = borderTopColor; + context.lineWidth = borderTopWidth; + context.moveTo(x, y); + context.lineTo(x + width, y); + context.stroke(); + + // Number = 1, + // Text = 2, + // LogicalValue = 4, + // ErrorValue = 16, + // Array = 64, + // CompoundData = 128, + + const cellType = this.model.getCellType(selectedSheet, row, column); + const fullText = this.model.getFormattedCellValue( + selectedSheet, + row, + column, + ); + const padding = 4; + if (horizontalAlign === "general") { + if (cellType === 1) { + horizontalAlign = "right"; + } else if (cellType === 4) { + horizontalAlign = "center"; + } else { + horizontalAlign = "left"; + } + } + + // Create a rectangular clipping region + context.save(); + context.beginPath(); + context.rect(x, y, width, height); + context.clip(); + + // Is there any better parameter? + const lineHeight = 22; + const lines = fullText.split("\n"); + const lineCount = lines.length; + + lines.forEach((text, line) => { + const textWidth = context.measureText(text).width; + let textX: number; + let textY: number; + // The idea is that in the present font-size and default row heigh, + // top/bottom and center horizontalAlign coincide + const verticalPadding = 4; + if (horizontalAlign === "right") { + textX = width - padding + x - textWidth / 2; + } else if (horizontalAlign === "center") { + textX = x + width / 2; + } else { + // left aligned + textX = padding + x + textWidth / 2; + } + if (verticalAlign === "bottom") { + textY = + y + + height - + fontSize / 2 - + verticalPadding + + (line - lineCount + 1) * lineHeight; + } else if (verticalAlign === "center") { + textY = y + height / 2 + (line + (1 - lineCount) / 2) * lineHeight; + } else { + // aligned top + textY = y + fontSize / 2 + verticalPadding + line * lineHeight; + } + context.fillText(text, textX, textY); + if (style.font) { + if (style.font.u) { + // There are no text-decoration in canvas. You have to do the underline yourself. + const offset = Math.floor(fontSize / 2); + context.beginPath(); + context.strokeStyle = textColor; + context.lineWidth = 1; + context.moveTo(textX - textWidth / 2, textY + offset); + context.lineTo(textX + textWidth / 2, textY + offset); + context.stroke(); + } + if (style.font.strike) { + // There are no text-decoration in canvas. You have to do the strikethrough yourself. + context.beginPath(); + context.strokeStyle = textColor; + context.lineWidth = 1; + context.moveTo(textX - textWidth / 2, textY); + context.lineTo(textX + textWidth / 2, textY); + context.stroke(); + } + } + }); + + // remove the clipping region + context.restore(); + } + + // Column and row headers with their handles + private addColumnResizeHandle( + x: number, + column: number, + columnWidth: number, + ): void { + const div = document.createElement("div"); + div.className = "column-resize-handle"; + div.style.left = `${x - 1}px`; + div.style.height = `${headerRowHeight}px`; + this.columnHeaders.insertBefore(div, null); + + let initPageX = 0; + const resizeHandleMove = (event: MouseEvent): void => { + if (columnWidth + event.pageX - initPageX > 0) { + div.style.left = `${x + event.pageX - initPageX - 1}px`; + this.columnGuide.style.left = `${ + headerColumnWidth + x + event.pageX - initPageX + }px`; + } + }; + let resizeHandleUp = (event: MouseEvent): void => { + div.style.opacity = "0"; + this.columnGuide.style.display = "none"; + document.removeEventListener("mousemove", resizeHandleMove); + document.removeEventListener("mouseup", resizeHandleUp); + const newColumnWidth = columnWidth + event.pageX - initPageX; + this.onColumnWidthChanges( + this.model.getSelectedSheet(), + column, + newColumnWidth, + ); + }; + resizeHandleUp = resizeHandleUp.bind(this); + div.addEventListener("mousedown", (event) => { + div.style.opacity = "1"; + this.columnGuide.style.display = "block"; + this.columnGuide.style.left = `${headerColumnWidth + x}px`; + initPageX = event.pageX; + document.addEventListener("mousemove", resizeHandleMove); + document.addEventListener("mouseup", resizeHandleUp); + }); + } + + private addRowResizeHandle(y: number, row: number, rowHeight: number): void { + const div = document.createElement("div"); + div.className = "row-resize-handle"; + div.style.top = `${y - 1}px`; + div.style.width = `${headerColumnWidth}px`; + const sheet = this.model.getSelectedSheet(); + this.canvas.parentElement?.insertBefore(div, null); + let initPageY = 0; + /* istanbul ignore next */ + const resizeHandleMove = (event: MouseEvent): void => { + if (rowHeight + event.pageY - initPageY > 0) { + div.style.top = `${y + event.pageY - initPageY - 1}px`; + this.rowGuide.style.top = `${y + event.pageY - initPageY}px`; + } + }; + let resizeHandleUp = (event: MouseEvent): void => { + div.style.opacity = "0"; + this.rowGuide.style.display = "none"; + document.removeEventListener("mousemove", resizeHandleMove); + document.removeEventListener("mouseup", resizeHandleUp); + const newRowHeight = rowHeight + event.pageY - initPageY - 1; + this.onRowHeightChanges(sheet, row, newRowHeight); + }; + resizeHandleUp = resizeHandleUp.bind(this); + /* istanbul ignore next */ + div.addEventListener("mousedown", (event) => { + div.style.opacity = "1"; + this.rowGuide.style.display = "block"; + this.rowGuide.style.top = `${y}px`; + initPageY = event.pageY; + document.addEventListener("mousemove", resizeHandleMove); + document.addEventListener("mouseup", resizeHandleUp); + }); + } + + private styleColumnHeader( + width: number, + div: HTMLDivElement, + selected: boolean, + ): void { + div.style.boxSizing = "border-box"; + div.style.width = `${width}px`; + div.style.height = `${headerRowHeight}px`; + div.style.backgroundColor = selected + ? headerSelectedBackground + : headerBackground; + div.style.color = selected ? headerSelectedColor : headerTextColor; + div.style.fontWeight = "bold"; + div.style.borderLeft = `1px solid ${headerBorderColor}`; + div.style.borderTop = `1px solid ${headerBorderColor}`; + if (selected) { + div.style.borderBottom = `1px solid ${outlineColor}`; + div.classList.add("selected"); + } else { + div.classList.remove("selected"); + } + } + + private removeHandles(): void { + const root = this.canvas.parentElement; + if (root) { + for (const handle of root.querySelectorAll(".row-resize-handle")) + handle.remove(); + } + } + + private renderRowHeaders( + frozenRows: number, + topLeftCell: Cell, + bottomRightCell: Cell, + ): void { + const { sheet: selectedSheet, range } = this.model.getSelectedView(); + let rowStart = range[0]; + let rowEnd = range[2]; + if (rowStart > rowEnd) { + [rowStart, rowEnd] = [rowEnd, rowStart]; + } + const context = this.ctx; + + let topLeftCornerY = headerRowHeight + 0.5; + const firstRow = frozenRows === 0 ? topLeftCell.row : 1; + + for (let row = firstRow; row <= bottomRightCell.row; row += 1) { + const rowHeight = this.getRowHeight(selectedSheet, row); + const selected = row >= rowStart && row <= rowEnd; + context.fillStyle = headerBorderColor; + context.fillRect(0.5, topLeftCornerY, headerColumnWidth, rowHeight); + context.fillStyle = selected + ? headerSelectedBackground + : headerBackground; + context.fillRect( + 0.5, + topLeftCornerY + 0.5, + headerColumnWidth, + rowHeight - 1, + ); + if (selected) { + context.fillStyle = outlineColor; + context.fillRect(headerColumnWidth - 1, topLeftCornerY, 1, rowHeight); + } + context.fillStyle = selected ? headerSelectedColor : headerTextColor; + context.font = `bold 12px ${defaultCellFontFamily}`; + context.fillText( + `${row}`, + headerColumnWidth / 2, + topLeftCornerY + rowHeight / 2, + headerColumnWidth, + ); + topLeftCornerY += rowHeight; + this.addRowResizeHandle(topLeftCornerY, row, rowHeight); + if (row === frozenRows) { + topLeftCornerY += frozenSeparatorWidth; + row = topLeftCell.row - 1; + } + } + } + + private renderColumnHeaders( + frozenColumns: number, + firstColumn: number, + lastColumn: number, + ): void { + const { columnHeaders } = this; + let deltaX = 0; + const { range } = this.model.getSelectedView(); + let columnStart = range[1]; + let columnEnd = range[3]; + if (columnStart > columnEnd) { + [columnStart, columnEnd] = [columnEnd, columnStart]; + } + for (const header of columnHeaders.querySelectorAll(".column-header")) + header.remove(); + for (const handle of columnHeaders.querySelectorAll( + ".column-resize-handle", + )) + handle.remove(); + for (const separator of columnHeaders.querySelectorAll( + ".frozen-column-separator", + )) + separator.remove(); + columnHeaders.style.fontFamily = headerFontFamily; + columnHeaders.style.fontSize = "12px"; + columnHeaders.style.height = `${headerRowHeight}px`; + columnHeaders.style.lineHeight = `${headerRowHeight}px`; + columnHeaders.style.left = `${headerColumnWidth}px`; + + // Frozen headers + for (let column = 1; column <= frozenColumns; column += 1) { + const selected = column >= columnStart && column <= columnEnd; + deltaX += this.addColumnHeader(deltaX, column, selected); + } + + if (frozenColumns !== 0) { + const div = document.createElement("div"); + div.className = "frozen-column-separator"; + div.style.width = `${frozenSeparatorWidth}px`; + div.style.height = `${headerRowHeight}`; + div.style.display = "inline-block"; + div.style.backgroundColor = gridSeparatorColor; + this.columnHeaders.insertBefore(div, null); + deltaX += frozenSeparatorWidth; + } + + for (let column = firstColumn; column <= lastColumn; column += 1) { + const selected = column >= columnStart && column <= columnEnd; + deltaX += this.addColumnHeader(deltaX, column, selected); + } + + columnHeaders.style.width = `${deltaX}px`; + } + + private addColumnHeader( + deltaX: number, + column: number, + selected: boolean, + ): number { + const columnWidth = this.getColumnWidth( + this.model.getSelectedSheet(), + column, + ); + const div = document.createElement("div"); + div.className = "column-header"; + div.textContent = columnNameFromNumber(column); + this.columnHeaders.insertBefore(div, null); + + this.styleColumnHeader(columnWidth, div, selected); + this.addColumnResizeHandle(deltaX + columnWidth, column, columnWidth); + return columnWidth; + } + + getSheetDimensions(): [number, number] { + let x = headerColumnWidth; + for (let column = 1; column < LAST_COLUMN + 1; column += 1) { + x += this.getColumnWidth(this.model.getSelectedSheet(), column); + } + let y = headerRowHeight; + for (let row = 1; row < LAST_ROW + 1; row += 1) { + y += this.getRowHeight(this.model.getSelectedSheet(), row); + } + this.sheetWidth = Math.floor( + x + this.getColumnWidth(this.model.getSelectedSheet(), LAST_COLUMN), + ); + this.sheetHeight = Math.floor( + y + 2 * this.getRowHeight(this.model.getSelectedSheet(), LAST_ROW), + ); + return [this.sheetWidth, this.sheetHeight]; + } + + /** + * Returns the css clip in the canvas of an html element + * This is used so we do not see the outlines in the row and column headers + * NB: A _different_ (better!) approach would be to have separate canvases for the headers + * Then the sheet canvas would have it's own bounding box. + * That's tomorrows problem. + * PS: Please, do not use this function. If at all we can use the clip-path property + */ + private getClipCSS( + x: number, + y: number, + width: number, + height: number, + includeFrozenRows: boolean, + includeFrozenColumns: boolean, + ): string { + if (!includeFrozenRows && !includeFrozenColumns) { + return ""; + } + const frozenColumnsWidth = includeFrozenColumns + ? this.getFrozenColumnsWidth() + : 0; + const frozenRowsHeight = includeFrozenRows ? this.getFrozenRowsHeight() : 0; + const yMinCanvas = headerRowHeight + frozenRowsHeight; + const xMinCanvas = headerColumnWidth + frozenColumnsWidth; + + const xMaxCanvas = + xMinCanvas + this.width - headerColumnWidth - frozenColumnsWidth; + const yMaxCanvas = + yMinCanvas + this.height - headerRowHeight - frozenRowsHeight; + + const topClip = y < yMinCanvas ? yMinCanvas - y : 0; + const leftClip = x < xMinCanvas ? xMinCanvas - x : 0; + + // We don't strictly need to clip on the right and bottom edges because + // text is hidden anyway + const rightClip = x + width > xMaxCanvas ? xMaxCanvas - x : width + 4; + const bottomClip = y + height > yMaxCanvas ? yMaxCanvas - y : height + 4; + return `rect(${topClip}px ${rightClip}px ${bottomClip}px ${leftClip}px)`; + } + + private getAreaDimensions( + startRow: number, + startColumn: number, + endRow: number, + endColumn: number, + ): [number, number] { + const [xStart, yStart] = this.getCoordinatesByCell(startRow, startColumn); + let [xEnd, yEnd] = this.getCoordinatesByCell(endRow, endColumn); + xEnd += this.getColumnWidth(this.model.getSelectedSheet(), endColumn); + yEnd += this.getRowHeight(this.model.getSelectedSheet(), endRow); + const frozenRows = this.model.getFrozenRowsCount( + this.model.getSelectedSheet(), + ); + const frozenColumns = this.model.getFrozenColumnsCount( + this.model.getSelectedSheet(), + ); + if (frozenRows !== 0 || frozenColumns !== 0) { + let [xFrozenEnd, yFrozenEnd] = this.getCoordinatesByCell( + frozenRows, + frozenColumns, + ); + xFrozenEnd += this.getColumnWidth( + this.model.getSelectedSheet(), + frozenColumns, + ); + yFrozenEnd += this.getRowHeight( + this.model.getSelectedSheet(), + frozenRows, + ); + if (startRow <= frozenRows && endRow > frozenRows) { + yEnd = Math.max(yEnd, yFrozenEnd); + } + if (startColumn <= frozenColumns && endColumn > frozenColumns) { + xEnd = Math.max(xEnd, xFrozenEnd); + } + } + return [Math.abs(xEnd - xStart), Math.abs(yEnd - yStart)]; + } + + /** + * Returns the coordinates relative to the canvas. + * (headerColumnWidth, headerRowHeight) being the coordinates + * for the top left corner of the first visible cell + */ + getCoordinatesByCell(row: number, column: number): [number, number] { + const selectedSheet = this.model.getSelectedSheet(); + const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet); + const frozenColumnsWidth = this.getFrozenColumnsWidth(); + const frozenRows = this.model.getFrozenRowsCount(selectedSheet); + const frozenRowsHeight = this.getFrozenRowsHeight(); + const { topLeftCell } = this.getVisibleCells(); + let x: number; + let y: number; + if (row <= frozenRows) { + // row is one of the frozen rows + y = headerRowHeight; + for (let r = 1; r < row; r += 1) { + y += this.getRowHeight(selectedSheet, r); + } + } else if (row >= topLeftCell.row) { + // row is bellow the frozen rows + y = headerRowHeight + frozenRowsHeight; + for (let r = topLeftCell.row; r < row; r += 1) { + y += this.getRowHeight(selectedSheet, r); + } + } else { + // row is _above_ the frozen rows + y = headerRowHeight + frozenRowsHeight; + for (let r = topLeftCell.row; r > row; r -= 1) { + y -= this.getRowHeight(selectedSheet, r - 1); + } + } + if (column <= frozenColumns) { + // It is one of the frozen columns + x = headerColumnWidth; + for (let c = 1; c < column; c += 1) { + x += this.getColumnWidth(selectedSheet, c); + } + } else if (column >= topLeftCell.column) { + // column is to the right of the frozen columns + x = headerColumnWidth + frozenColumnsWidth; + for (let c = topLeftCell.column; c < column; c += 1) { + x += this.getColumnWidth(selectedSheet, c); + } + } else { + // column is to the left of the frozen columns + x = headerColumnWidth + frozenColumnsWidth; + for (let c = topLeftCell.column; c > column; c -= 1) { + x -= this.getColumnWidth(selectedSheet, c - 1); + } + } + return [Math.floor(x), Math.floor(y)]; + } + + /** + * (x, y) are the relative coordinates of a cell WRT the canvas + * getCellByCoordinates(headerColumnWidth, headerRowHeight) will return the first visible cell. + * Note: If there are frozen rows/columns for some particular coordinates (x, y) + * there might be two cells. This method returns the visible one. + */ + getCellByCoordinates( + x: number, + y: number, + ): { row: number; column: number } | null { + const frozenColumns = this.model.getFrozenColumnsCount( + this.model.getSelectedSheet(), + ); + const frozenColumnsWidth = this.getFrozenColumnsWidth(); + const frozenRows = this.model.getFrozenRowsCount( + this.model.getSelectedSheet(), + ); + const frozenRowsHeight = this.getFrozenRowsHeight(); + let column = 0; + let cellX = headerColumnWidth; + const { topLeftCell } = this.getVisibleCells(); + if (x < headerColumnWidth) { + column = topLeftCell.column; + while (cellX >= x) { + column -= 1; + if (column < 1) { + column = 1; + break; + } + cellX -= this.getColumnWidth(this.model.getSelectedSheet(), column); + } + } else if (x < headerColumnWidth + frozenColumnsWidth) { + while (cellX <= x) { + column += 1; + cellX += this.getColumnWidth(this.model.getSelectedSheet(), column); + // This cannot happen (would mean cellX > headerColumnWidth + frozenColumnsWidth) + if (column > frozenColumns) { + /* istanbul ignore next */ + return null; + } + } + } else { + cellX = headerColumnWidth + frozenColumnsWidth; + column = topLeftCell.column - 1; + while (cellX <= x) { + column += 1; + if (column > LAST_COLUMN) { + return null; + } + cellX += this.getColumnWidth(this.model.getSelectedSheet(), column); + } + } + let cellY = headerRowHeight; + let row = 0; + if (y < headerRowHeight) { + row = topLeftCell.row; + while (cellY >= y) { + row -= 1; + if (row < 1) { + row = 1; + break; + } + cellY -= this.getRowHeight(this.model.getSelectedSheet(), row); + } + } else if (y < headerRowHeight + frozenRowsHeight) { + while (cellY <= y) { + row += 1; + cellY += this.getRowHeight(this.model.getSelectedSheet(), row); + // This cannot happen (would mean cellY > headerRowHeight + frozenRowsHeight) + if (row > frozenRows) { + /* istanbul ignore next */ + return null; + } + } + } else { + cellY = headerRowHeight + frozenRowsHeight; + row = topLeftCell.row - 1; + while (cellY <= y) { + row += 1; + if (row > LAST_ROW) { + row = LAST_ROW; + break; + } + cellY += this.getRowHeight(this.model.getSelectedSheet(), row); + } + } + if (row < 1) row = 1; + if (column < 1) column = 1; + return { row, column }; + } + + private drawExtendToArea(): void { + const { extendToOutline } = this; + const extendToArea = this.workbookState.getExtendToArea(); + if (extendToArea === null) { + extendToOutline.style.visibility = "hidden"; + return; + } + extendToOutline.style.visibility = "visible"; + + let { rowStart, rowEnd, columnStart, columnEnd } = extendToArea; + if (rowStart > rowEnd) { + [rowStart, rowEnd] = [rowEnd, rowStart]; + } + if (columnStart > columnEnd) { + [columnStart, columnEnd] = [columnEnd, columnStart]; + } + + const [areaX, areaY] = this.getCoordinatesByCell(rowStart, columnStart); + const [areaWidth, areaHeight] = this.getAreaDimensions( + rowStart, + columnStart, + rowEnd, + columnEnd, + ); + extendToOutline.style.border = `1px dashed ${outlineColor}`; + extendToOutline.style.borderRadius = "3px"; + + extendToOutline.style.left = `${areaX}px`; + extendToOutline.style.top = `${areaY}px`; + extendToOutline.style.width = `${areaWidth - 1}px`; + extendToOutline.style.height = `${areaHeight - 1}px`; + } + + private getColumnWidth(sheet: number, column: number): number { + return Math.round( + this.model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE, + ); + } + + private getRowHeight(sheet: number, row: number): number { + return Math.round(this.model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE); + } + + private drawCellEditor(): void { + const cell = this.workbookState.getEditingCell(); + const selectedSheet = this.model.getSelectedSheet(); + const { editor } = this; + if (!cell || cell.sheet !== selectedSheet) { + // 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"; + return; + } + const { row, column } = cell; + // const style = this.model.getCellStyle( + // selectedSheet, + // selectedRow, + // selectedColumn + // ); + // cellOutline.style.fontWeight = style.font.b ? "bold" : "normal"; + // cellOutline.style.fontStyle = style.font.i ? "italic" : "normal"; + // cellOutline.style.backgroundColor = style.fill.fg_color; + // TODO: Should we add the same color as the text? + // Only if it is not a formula? + // cellOutline.style.color = style.font.color; + const [x, y] = this.getCoordinatesByCell(row, column); + const padding = -1; + const width = cell.editorWidth + 2 * padding; + const height = cell.editorHeight + 2 * padding; + // const width = + // this.getColumnWidth(sheet, column) + 2 * padding; + // const height = this.getRowHeight(sheet, row) + 2 * padding; + editor.style.left = `${x}px`; + editor.style.top = `${y}px`; + editor.style.width = `${width - 1}px`; + editor.style.height = `${height - 1}px`; + } + + private drawCellOutline(): void { + const { cellOutline, areaOutline, cellOutlineHandle } = this; + if (this.workbookState.getEditingCell()) { + cellOutline.style.visibility = "hidden"; + cellOutlineHandle.style.visibility = "hidden"; + areaOutline.style.visibility = "hidden"; + return; + } + cellOutline.style.visibility = "visible"; + cellOutlineHandle.style.visibility = "visible"; + areaOutline.style.visibility = "visible"; + + const [selectedSheet, selectedRow, selectedColumn] = + this.model.getSelectedCell(); + const { topLeftCell } = this.getVisibleCells(); + const frozenRows = this.model.getFrozenRowsCount(selectedSheet); + const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet); + const [x, y] = this.getCoordinatesByCell(selectedRow, selectedColumn); + + const padding = -1; + const width = + this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding; + const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding; + + if ( + (selectedRow < topLeftCell.row && selectedRow > frozenRows) || + (selectedColumn < topLeftCell.column && selectedColumn > frozenColumns) + ) { + cellOutline.style.visibility = "hidden"; + cellOutlineHandle.style.visibility = "hidden"; + } + + // Position the cell outline and clip it + cellOutline.style.left = `${x - padding - 2}px`; + cellOutline.style.top = `${y - padding - 2}px`; + // Reset CSS properties + cellOutline.style.minWidth = ""; + cellOutline.style.minHeight = ""; + cellOutline.style.maxWidth = ""; + cellOutline.style.maxHeight = ""; + cellOutline.style.overflow = "hidden"; + // New properties + cellOutline.style.width = `${width + 1}px`; + cellOutline.style.height = `${height + 1}px`; + + cellOutline.style.background = "none"; + + // border is 2px so line-height must be height - 4 + cellOutline.style.lineHeight = `${height - 4}px`; + let { + range: [rowStart, columnStart, rowEnd, columnEnd], + } = this.model.getSelectedView(); + if (rowStart > rowEnd) { + [rowStart, rowEnd] = [rowEnd, rowStart]; + } + if (columnStart > columnEnd) { + [columnStart, columnEnd] = [columnEnd, columnStart]; + } + let handleX: number; + let handleY: number; + // Position the selected area outline + if (columnStart === columnEnd && rowStart === rowEnd) { + areaOutline.style.visibility = "hidden"; + [handleX, handleY] = this.getCoordinatesByCell(rowStart, columnStart); + handleX += this.getColumnWidth(selectedSheet, columnStart); + handleY += this.getRowHeight(selectedSheet, rowStart); + } else { + areaOutline.style.visibility = "visible"; + cellOutlineHandle.style.visibility = "visible"; + const [areaX, areaY] = this.getCoordinatesByCell(rowStart, columnStart); + const [areaWidth, areaHeight] = this.getAreaDimensions( + rowStart, + columnStart, + rowEnd, + columnEnd, + ); + handleX = areaX + areaWidth; + handleY = areaY + areaHeight; + areaOutline.style.left = `${areaX - padding - 1}px`; + areaOutline.style.top = `${areaY - padding - 1}px`; + areaOutline.style.width = `${areaWidth + 2 * padding + 1}px`; + areaOutline.style.height = `${areaHeight + 2 * padding + 1}px`; + const clipLeft = rowStart < topLeftCell.row && rowStart > frozenRows; + const clipTop = + columnStart < topLeftCell.column && columnStart > frozenColumns; + areaOutline.style.clip = this.getClipCSS( + areaX, + areaY, + areaWidth + 2 * padding, + areaHeight + 2 * padding, + clipLeft, + clipTop, + ); + areaOutline.style.border = `1px solid ${outlineColor}`; + // hide the handle if it is out of the visible area + if ( + (rowEnd > frozenRows && rowEnd < topLeftCell.row - 1) || + (columnEnd > frozenColumns && columnEnd < topLeftCell.column - 1) + ) { + cellOutlineHandle.style.visibility = "hidden"; + } + + // This is in case the selection starts in the frozen area and ends outside of the frozen area + // but we have scrolled out the selection. + if ( + rowStart <= frozenRows && + rowEnd > frozenRows && + rowEnd < topLeftCell.row - 1 + ) { + areaOutline.style.borderBottom = "None"; + cellOutlineHandle.style.visibility = "hidden"; + } + if ( + columnStart <= frozenColumns && + columnEnd > frozenColumns && + columnEnd < topLeftCell.column - 1 + ) { + areaOutline.style.borderRight = "None"; + cellOutlineHandle.style.visibility = "hidden"; + } + } + + const handleBBox = cellOutlineHandle.getBoundingClientRect(); + const handleWidth = handleBBox.width; + const handleHeight = handleBBox.height; + cellOutlineHandle.style.left = `${handleX - handleWidth / 2 - 1}px`; + cellOutlineHandle.style.top = `${handleY - handleHeight / 2 - 1}px`; + } + + private drawCutRange(): void { + const range = this.workbookState.getCutRange() || null; + if (!range) { + return; + } + const selectedSheet = this.model.getSelectedSheet(); + if (range.sheet !== selectedSheet) { + return; + } + const ctx = this.ctx; + ctx.setLineDash([2, 2]); + + const [xStart, yStart] = this.getCoordinatesByCell( + range.rowStart, + range.columnStart, + ); + const [xEnd, yEnd] = this.getCoordinatesByCell( + range.rowEnd + 1, + range.columnEnd + 1, + ); + ctx.strokeStyle = "red"; + ctx.lineWidth = 1; + ctx.strokeRect(xStart, yStart, xEnd - xStart, yEnd - yStart); + + ctx.setLineDash([]); + } + + 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: getColor(activeRanges.length), + }, + ]); + } + const selectedSheet = this.model.getSelectedSheet(); + const activeRangesCount = activeRanges.length; + for (let rangeIndex = 0; rangeIndex < activeRangesCount; rangeIndex += 1) { + const range = activeRanges[rangeIndex]; + if (range.sheet !== selectedSheet) { + continue; + } + + 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 { + const context = this.ctx; + const { canvas } = this; + const selectedSheet = this.model.getSelectedSheet(); + context.lineWidth = 1; + context.textAlign = "center"; + context.textBaseline = "middle"; + + // Clear the canvas + context.clearRect(0, 0, canvas.width, canvas.height); + + this.removeHandles(); + + const { topLeftCell, bottomRightCell } = this.getVisibleCells(); + + const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet); + const frozenRows = this.model.getFrozenRowsCount(selectedSheet); + + // Draw frozen rows and columns (top-left-pane) + let x = headerColumnWidth + 0.5; + let y = headerRowHeight + 0.5; + for (let row = 1; row <= frozenRows; row += 1) { + const rowHeight = this.getRowHeight(selectedSheet, row); + x = headerColumnWidth; + for (let column = 1; column <= frozenColumns; column += 1) { + const columnWidth = this.getColumnWidth(selectedSheet, column); + this.renderCell(row, column, x, y, columnWidth, rowHeight); + x += columnWidth; + } + y += rowHeight; + } + if (frozenRows === 0 && frozenColumns !== 0) { + x = headerColumnWidth; + for (let column = 1; column <= frozenColumns; column += 1) { + x += this.getColumnWidth(selectedSheet, column); + } + } + + const frozenOffset = frozenSeparatorWidth / 2; + // If there are frozen rows draw a separator + if (frozenRows) { + context.beginPath(); + context.lineWidth = frozenSeparatorWidth; + context.strokeStyle = gridSeparatorColor; + context.moveTo(0, y + frozenOffset); + context.lineTo(this.width, y + frozenOffset); + y += frozenSeparatorWidth; + context.stroke(); + context.lineWidth = 1; + } + + // If there are frozen columns draw a separator + if (frozenColumns) { + context.beginPath(); + context.lineWidth = frozenSeparatorWidth; + context.strokeStyle = gridSeparatorColor; + context.moveTo(x + frozenOffset, 0); + context.lineTo(x + frozenOffset, this.height); + x += frozenSeparatorWidth; + context.stroke(); + context.lineWidth = 1; + } + + const frozenX = x; + const frozenY = y; + // Draw frozen rows (top-right pane) + y = headerRowHeight; + for (let row = 1; row <= frozenRows; row += 1) { + x = frozenX; + const rowHeight = this.getRowHeight(selectedSheet, row); + for ( + let { column } = topLeftCell; + column <= bottomRightCell.column; + column += 1 + ) { + const columnWidth = this.getColumnWidth(selectedSheet, column); + this.renderCell(row, column, x, y, columnWidth, rowHeight); + x += columnWidth; + } + y += rowHeight; + } + + // Draw frozen columns (bottom-left pane) + y = frozenY; + for (let { row } = topLeftCell; row <= bottomRightCell.row; row += 1) { + x = headerColumnWidth; + const rowHeight = this.getRowHeight(selectedSheet, row); + + for (let column = 1; column <= frozenColumns; column += 1) { + const columnWidth = this.getColumnWidth(selectedSheet, column); + this.renderCell(row, column, x, y, columnWidth, rowHeight); + + x += columnWidth; + } + y += rowHeight; + } + + // Render all remaining cells (bottom-right pane) + y = frozenY; + for (let { row } = topLeftCell; row <= bottomRightCell.row; row += 1) { + x = frozenX; + const rowHeight = this.getRowHeight(selectedSheet, row); + + for ( + let { column } = topLeftCell; + column <= bottomRightCell.column; + column += 1 + ) { + const columnWidth = this.getColumnWidth(selectedSheet, column); + this.renderCell(row, column, x, y, columnWidth, rowHeight); + + x += columnWidth; + } + y += rowHeight; + } + + // Draw column headers + this.renderColumnHeaders( + frozenColumns, + topLeftCell.column, + bottomRightCell.column, + ); + + // Draw row headers + this.renderRowHeaders(frozenRows, topLeftCell, bottomRightCell); + + // square in the top left corner + context.beginPath(); + context.strokeStyle = gridSeparatorColor; + context.moveTo(0, 0.5); + context.lineTo(x + headerColumnWidth, 0.5); + context.stroke(); + + this.drawCellOutline(); + this.drawCellEditor(); + this.drawExtendToArea(); + this.drawActiveRanges(topLeftCell, bottomRightCell); + this.drawCutRange(); + } +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/borderPicker.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/borderPicker.tsx new file mode 100644 index 000000000..5e7d16f65 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/borderPicker.tsx @@ -0,0 +1,484 @@ +import { type BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm"; +import Popover, { type PopoverOrigin } from "@mui/material/Popover"; +import { styled } from "@mui/material/styles"; +import { + Grid2X2 as BorderAllIcon, + ChevronRight, + PencilLine, +} from "lucide-react"; +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + BorderBottomIcon, + BorderCenterHIcon, + BorderCenterVIcon, + BorderInnerIcon, + BorderLeftIcon, + BorderNoneIcon, + BorderOuterIcon, + BorderRightIcon, + BorderStyleIcon, + BorderTopIcon, +} from "../icons"; +import { theme } from "../theme"; +import ColorPicker from "./colorPicker"; + +type BorderPickerProps = { + className?: string; + onChange: (border: BorderOptions) => void; + onClose: () => void; + anchorEl: React.RefObject; + anchorOrigin?: PopoverOrigin; + transformOrigin?: PopoverOrigin; + open: boolean; +}; + +const BorderPicker = (properties: BorderPickerProps) => { + const { t } = useTranslation(); + + const [borderSelected, setBorderSelected] = useState(null); + const [borderColor, setBorderColor] = useState("#000000"); + const [borderStyle, setBorderStyle] = useState(BorderStyle.Thin); + const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [stylePickerOpen, setStylePickerOpen] = useState(false); + + // FIXME + // biome-ignore lint/correctness/useExhaustiveDependencies: We don't want updating the function every time the properties.onChange + useEffect(() => { + if (!borderSelected) { + return; + } + properties.onChange({ + color: borderColor, + style: borderStyle, + border: borderSelected, + }); + }, [borderColor, borderStyle, borderSelected]); + + const onClose = properties.onClose; + + // The reason is that the border picker doesn't start with the properties of the selected area + // biome-ignore lint/correctness/useExhaustiveDependencies: We reset the styles, every time we open (or close) the widget + useEffect(() => { + setBorderSelected(null); + setBorderColor("#000000"); + setBorderStyle(BorderStyle.Thin); + }, [properties.open]); + + const borderColorButton = useRef(null); + const borderStyleButton = useRef(null); + return ( + +
+ + + + + + + + + + + + + + + + + + + + setColorPickerOpen(true)}> + +
Border color
+ +
+ setStylePickerOpen(true)} + ref={borderStyleButton} + > + +
Border style
+ +
+
+
+ { + setBorderColor(color); + setColorPickerOpen(false); + }} + onClose={() => { + setColorPickerOpen(false); + }} + anchorEl={borderColorButton} + open={colorPickerOpen} + /> + { + setStylePickerOpen(false); + }} + anchorEl={borderStyleButton.current} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: 38, horizontal: -6 }} + > + + { + setBorderStyle(BorderStyle.Thin); + setStylePickerOpen(false); + }} + $checked={borderStyle === BorderStyle.Thin} + > + Thin + + + { + setBorderStyle(BorderStyle.Medium); + setStylePickerOpen(false); + }} + $checked={borderStyle === BorderStyle.Medium} + > + Medium + + + { + setBorderStyle(BorderStyle.Thick); + setStylePickerOpen(false); + }} + $checked={borderStyle === BorderStyle.Thick} + > + Thick + + + + +
+
+ ); +}; + +type LineWrapperProperties = { $checked: boolean }; +const LineWrapper = styled("div")` + display: flex; + flex-direction: row; + align-items: center; + background-color: ${({ $checked }): string => { + if ($checked) { + return "#EEEEEE;"; + } + return "inherit;"; + }}; + &:hover { + border: 1px solid #eeeeee; + } + padding: 8px; + cursor: pointer; + border-radius: 4px; + border: 1px solid white; +`; + +const SolidLine = styled("div")` + width: 68px; + border-top: 1px solid #333333; +`; +const MediumLine = styled("div")` + width: 68px; + border-top: 2px solid #333333; +`; +const ThickLine = styled("div")` + width: 68px; + border-top: 3px solid #333333; +`; + +const Divider = styled("div")` + display: inline-flex; + heigh: 1px; + border-bottom: 1px solid #eee; + margin-left: 0px; + margin-right: 0px; +`; + +const Borders = styled("div")` + display: flex; + flex-direction: column; + padding-bottom: 4px; +`; + +const Styles = styled("div")` + display: flex; + flex-direction: column; +`; + +const Line = styled("div")` + display: flex; + flex-direction: row; + align-items: center; +`; + +const ButtonWrapper = styled("div")` + display: flex; + flex-direction: row; + align-items: center; + &:hover { + background-color: #eee; + border-top-color: ${(): string => theme.palette.grey["400"]}; + } + cursor: pointer; + padding: 8px; +`; + +const BorderStyleDialog = styled("div")` + background: ${({ theme }): string => theme.palette.background.default}; + padding: 4px; + display: flex; + flex-direction: column; + align-items: center; +`; + +const StyledPopover = styled(Popover)` + .MuiPopover-paper { + border-radius: 10px; + border: 0px solid ${({ theme }): string => theme.palette.background.default}; + box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5); + } + .MuiPopover-padding { + padding: 0px; + } + .MuiList-padding { + padding: 0px; + } + font-family: ${({ theme }) => theme.typography.fontFamily}; + font-size: 12px; +`; + +const BorderPickerDialog = styled("div")` + background: ${({ theme }): string => theme.palette.background.default}; + padding: 4px; + display: flex; + flex-direction: column; +`; + +const BorderDescription = styled("div")` + width: 70px; +`; + +type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string }; +const Button = styled("button")( + ({ disabled, $pressed, $underlinedColor }) => { + const result = { + width: "24px", + height: "24px", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + // fontSize: "26px", + border: "0px solid #fff", + borderRadius: "4px", + marginRight: "5px", + transition: "all 0.2s", + cursor: "pointer", + padding: "0px", + }; + if (disabled) { + return { + ...result, + color: theme.palette.grey["600"], + cursor: "default", + }; + } + return { + ...result, + borderTop: $underlinedColor ? "3px solid #FFF" : "none", + borderBottom: $underlinedColor ? `3px solid ${$underlinedColor}` : "none", + color: "#21243A", + backgroundColor: $pressed ? theme.palette.grey["200"] : "inherit", + "&:hover": { + backgroundColor: "#F1F2F8", + borderTopColor: "#F1F2F8", + }, + svg: { + width: "16px", + height: "16px", + }, + }; + }, +); + +const ChevronRightStyled = styled(ChevronRight)` + width: 16px; + height: 16px; +`; + +export default BorderPicker; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/clipboard.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/clipboard.ts new file mode 100644 index 000000000..4a21d8a05 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/clipboard.ts @@ -0,0 +1,2 @@ +export const getNewClipboardId = () => new Date().toISOString(); +export const CLIPBOARD_ID_SESSION_STORAGE_KEY = "IronCalc-Clipboard"; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/colorPicker.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/colorPicker.tsx new file mode 100644 index 000000000..1ebc4832c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/ironcalc/components/colorPicker.tsx @@ -0,0 +1,279 @@ +import styled from "@emotion/styled"; +import Popover, { type PopoverOrigin } from "@mui/material/Popover"; +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import { HexColorInput, HexColorPicker } from "react-colorful"; +import { theme } from "../theme"; + +type ColorPickerProps = { + className?: string; + color: string; + onChange: (color: string) => void; + onClose: () => void; + anchorEl: React.RefObject; + anchorOrigin?: PopoverOrigin; + transformOrigin?: PopoverOrigin; + open: boolean; +}; + +const colorPickerWidth = 240; +const colorfulHeight = 185; // 150 + 15 + 20 + +const ColorPicker = (properties: ColorPickerProps) => { + const [color, setColor] = useState(properties.color); + const recentColors = useRef([]); + + const closePicker = (newColor: string): void => { + const maxRecentColors = 14; + properties.onChange(newColor); + const colors = recentColors.current.filter((c) => c !== newColor); + recentColors.current = [newColor, ...colors].slice(0, maxRecentColors); + }; + + const handleClose = (): void => { + properties.onClose(); + }; + + useEffect(() => { + setColor(properties.color); + }, [properties.color]); + + const presetColors = [ + "#FFFFFF", + "#1B717E", + "#59B9BC", + "#3BB68A", + "#8CB354", + "#F8CD3C", + "#EC5753", + "#A23C52", + "#D03627", + "#523E93", + "#3358B7", + ]; + + return ( + + + { + setColor(newColor); + }} + /> + + + {"Hex"} + + {"#"} + { + setColor(newColor); + }} + /> + + + { + closePicker(color); + }} + /> + + + + {presetColors.map((presetColor) => ( +