import type { BorderOptions, ClipboardCell, Model, WorksheetProperties, } from "@ironcalc/wasm"; import { styled } from "@mui/material/styles"; import { useCallback, useEffect, useRef, useState } from "react"; import { CLIPBOARD_ID_SESSION_STORAGE_KEY, getNewClipboardId, } from "../clipboard"; import { TOOLBAR_HEIGHT } from "../constants"; import FormulaBar from "../FormulaBar/FormulaBar"; import RightDrawer, { DEFAULT_DRAWER_WIDTH } from "../RightDrawer/RightDrawer"; import SheetTabBar from "../SheetTabBar"; import Toolbar from "../Toolbar/Toolbar"; import { getCellAddress, getFullRangeToString, type NavigationKey, } from "../util"; import Worksheet from "../Worksheet/Worksheet"; import { COLUMN_WIDTH_SCALE, LAST_COLUMN, LAST_ROW, ROW_HEIGH_SCALE, } from "../WorksheetCanvas/constants"; import type WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas"; import { devicePixelRatio } from "../WorksheetCanvas/worksheetCanvas"; import type { WorkbookState } from "../workbookState"; import useKeyboardNavigation from "./useKeyboardNavigation"; const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { const { model, workbookState } = props; const rootRef = useRef(null); const worksheetRef = useRef<{ getCanvas: () => WorksheetCanvas | null; }>(null); // Calling `setRedrawId((id) => id + 1);` forces a redraw // This is needed because `model` or `workbookState` can change without React being aware of it const setRedrawId = useState(0)[1]; const [isDrawerOpen, setDrawerOpen] = useState(false); const [drawerWidth, setDrawerWidth] = useState(DEFAULT_DRAWER_WIDTH); const worksheets = model.getWorksheetsProperties(); const info = worksheets.map( ({ name, color, sheet_id, state }: WorksheetProperties) => { return { name, color: color ? color : "#FFF", sheetId: sheet_id, state }; }, ); const focusWorkbook = useCallback(() => { if (rootRef.current) { rootRef.current.focus({ preventScroll: true }); // HACK: We need to select something inside the root for onCopy to work const selection = window.getSelection(); if (selection) { selection.empty(); const range = new Range(); range.setStart(rootRef.current.firstChild as Node, 0); range.setEnd(rootRef.current.firstChild as Node, 0); selection.addRange(range); } } }, []); const onRedo = () => { model.redo(); setRedrawId((id) => id + 1); }; const onUndo = () => { model.undo(); setRedrawId((id) => id + 1); }; const updateRangeStyle = (stylePath: string, value: string) => { const { sheet, range: [rowStart, columnStart, rowEnd, columnEnd], } = model.getSelectedView(); const row = Math.min(rowStart, rowEnd); const column = Math.min(columnStart, columnEnd); const range = { sheet, row, column, width: Math.abs(columnEnd - columnStart) + 1, height: Math.abs(rowEnd - rowStart) + 1, }; model.updateRangeStyle(range, stylePath, value); setRedrawId((id) => id + 1); }; const onToggleUnderline = (value: boolean) => { updateRangeStyle("font.u", `${value}`); }; const onToggleItalic = (value: boolean) => { updateRangeStyle("font.i", `${value}`); }; const onToggleBold = (value: boolean) => { updateRangeStyle("font.b", `${value}`); }; const onToggleStrike = (value: boolean) => { updateRangeStyle("font.strike", `${value}`); }; const onToggleHorizontalAlign = (value: string) => { updateRangeStyle("alignment.horizontal", value); }; const onToggleVerticalAlign = (value: string) => { updateRangeStyle("alignment.vertical", value); }; const onToggleWrapText = (value: boolean) => { updateRangeStyle("alignment.wrap_text", `${value}`); }; const onTextColorPicked = (hex: string) => { updateRangeStyle("font.color", hex); }; const onFillColorPicked = (hex: string) => { updateRangeStyle("fill.fg_color", hex); }; const onNumberFormatPicked = (numberFmt: string) => { updateRangeStyle("num_fmt", numberFmt); }; const onIncreaseFontSize = (delta: number) => { updateRangeStyle("font.size_delta", `${delta}`); }; const onCopyStyles = () => { const { sheet, range: [rowStart, columnStart, rowEnd, columnEnd], } = model.getSelectedView(); const row1 = Math.min(rowStart, rowEnd); const column1 = Math.min(columnStart, columnEnd); const row2 = Math.max(rowStart, rowEnd); const column2 = Math.max(columnStart, columnEnd); const styles = []; for (let row = row1; row <= row2; row++) { const styleRow = []; for (let column = column1; column <= column2; column++) { styleRow.push(model.getCellStyle(sheet, row, column)); } styles.push(styleRow); } workbookState.setCopyStyles(styles); // FIXME: This is so that the cursor indicates there are styles to be pasted const el = rootRef.current?.getElementsByClassName("sheet-container")[0]; if (el) { // Taken from lucide icons: and rotated. const svg = ``; (el as HTMLElement).style.cursor = `url('data:image/svg+xml;utf8,${encodeURIComponent(svg)}'), auto`; } }; // FIXME: I *think* we should have only one on onKeyPressed function that goes to // the Rust end const { onKeyDown } = useKeyboardNavigation({ onCellsDeleted: (): void => { const { sheet, range: [rowStart, columnStart, rowEnd, columnEnd], } = model.getSelectedView(); const row = Math.min(rowStart, rowEnd); const column = Math.min(columnStart, columnEnd); const width = Math.abs(columnEnd - columnStart); const height = Math.abs(rowEnd - rowStart); model.rangeClearContents( sheet, row, column, row + height, column + width, ); setRedrawId((id) => id + 1); }, onExpandAreaSelectedKeyboard: ( key: "ArrowRight" | "ArrowLeft" | "ArrowUp" | "ArrowDown", ): void => { model.onExpandSelectedRange(key); setRedrawId((id) => id + 1); }, onEditKeyPressStart: (initText: string): void => { const { sheet, row, column } = model.getSelectedView(); const editorWidth = model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE; const editorHeight = model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE; workbookState.setEditingCell({ sheet, row, column, text: initText, cursorStart: initText.length, cursorEnd: initText.length, focus: "cell", referencedRange: null, activeRanges: [], mode: "accept", editorWidth, editorHeight, }); setRedrawId((id) => id + 1); }, onCellEditStart: (): void => { // User presses F2, we start editing at the edn of the text const { sheet, row, column } = model.getSelectedView(); const text = model.getCellContent(sheet, row, column); const editorWidth = model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE; const editorHeight = model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE; workbookState.setEditingCell({ sheet, row, column, text, cursorStart: text.length, cursorEnd: text.length, referencedRange: null, focus: "cell", activeRanges: [], mode: "edit", editorWidth, editorHeight, }); setRedrawId((id) => id + 1); }, onBold: () => { const { sheet, row, column } = model.getSelectedView(); const value = model.getCellStyle(sheet, row, column).font.b; onToggleBold(!value); }, onItalic: () => { const { sheet, row, column } = model.getSelectedView(); const value = model.getCellStyle(sheet, row, column).font.i; onToggleItalic(!value); }, onUnderline: () => { const { sheet, row, column } = model.getSelectedView(); const value = model.getCellStyle(sheet, row, column).font.u; onToggleUnderline(!value); }, onNavigationToEdge: (direction: NavigationKey): void => { model.onNavigateToEdgeInDirection(direction); setRedrawId((id) => id + 1); }, onPageDown: (): void => { model.onPageDown(); setRedrawId((id) => id + 1); }, onPageUp: (): void => { model.onPageUp(); setRedrawId((id) => id + 1); }, onArrowDown: (): void => { model.onArrowDown(); setRedrawId((id) => id + 1); }, onArrowUp: (): void => { model.onArrowUp(); setRedrawId((id) => id + 1); }, onArrowLeft: (): void => { model.onArrowLeft(); setRedrawId((id) => id + 1); }, onArrowRight: (): void => { model.onArrowRight(); setRedrawId((id) => id + 1); }, onKeyHome: (): void => { const view = model.getSelectedView(); const cell = model.getSelectedCell(); model.setSelectedCell(cell[1], 1); model.setTopLeftVisibleCell(view.top_row, 1); setRedrawId((id) => id + 1); }, onKeyEnd: (): void => { const view = model.getSelectedView(); const cell = model.getSelectedCell(); model.setSelectedCell(cell[1], LAST_COLUMN); model.setTopLeftVisibleCell(view.top_row, LAST_COLUMN - 5); setRedrawId((id) => id + 1); }, onUndo: (): void => { model.undo(); setRedrawId((id) => id + 1); }, onRedo: (): void => { model.redo(); setRedrawId((id) => id + 1); }, onNextSheet: (): void => { const nextSheet = model.getSelectedSheet() + 1; if (nextSheet >= model.getWorksheetsProperties().length) { model.setSelectedSheet(0); } else { model.setSelectedSheet(nextSheet); } }, onPreviousSheet: (): void => { const nextSheet = model.getSelectedSheet() - 1; if (nextSheet < 0) { model.setSelectedSheet(model.getWorksheetsProperties().length - 1); } else { model.setSelectedSheet(nextSheet); } }, onEscape: (): void => { workbookState.clearCutRange(); setRedrawId((id) => id + 1); }, onSelectColumn: (): void => { const { column } = model.getSelectedView(); model.setSelectedRange(1, column, LAST_ROW, column); setRedrawId((id) => id + 1); }, onSelectRow: (): void => { const { row } = model.getSelectedView(); model.setSelectedRange(row, 1, row, LAST_COLUMN); setRedrawId((id) => id + 1); }, root: rootRef, }); useEffect(() => { if (!rootRef.current) { return; } if (!workbookState.getEditingCell()) { focusWorkbook(); } }); const cellAddress = useCallback(() => { const { row, column, range: [rowStart, columnStart, rowEnd, columnEnd], } = model.getSelectedView(); return getCellAddress( { rowStart, rowEnd, columnStart, columnEnd }, { row, column }, ); }, [model]); const formulaValue = () => { const cell = workbookState.getEditingCell(); if (cell) { return workbookState.getEditingText(); } const { sheet, row, column } = model.getSelectedView(); return model.getCellContent(sheet, row, column); }; const getCellStyle = useCallback(() => { const { sheet, row, column } = model.getSelectedView(); return model.getCellStyle(sheet, row, column); }, [model]); const style = getCellStyle(); return ( { if (!workbookState.getEditingCell()) { focusWorkbook(); } else { event.stopPropagation(); } }} onPaste={(event: React.ClipboardEvent) => { workbookState.clearCutRange(); const { items } = event.clipboardData; if (!items) { return; } const mimeTypes = [ "application/json", "text/plain", "text/csv", "text/html", ]; let mimeType = null; let value = null; for (let index = 0; index < mimeTypes.length; index += 1) { mimeType = mimeTypes[index]; value = event.clipboardData.getData(mimeType); if (value) { break; } } if (!mimeType || !value) { // No clipboard data to paste return; } if (mimeType === "application/json") { // We are copying from within the application const source = JSON.parse(value); // const clipboardId = sessionStorage.getItem( // CLIPBOARD_ID_SESSION_STORAGE_KEY // ); const data: Map> = new Map(); const sheetData = source.sheetData; for (const row of Object.keys(sheetData)) { const dataRow = sheetData[row]; const rowMap = new Map(); for (const column of Object.keys(dataRow)) { rowMap.set(Number.parseInt(column, 10), dataRow[column]); } data.set(Number.parseInt(row, 10), rowMap); } model.pasteFromClipboard( source.sheet, source.area, data, source.type === "cut", ); setRedrawId((id) => id + 1); } else if (mimeType === "text/plain") { const { sheet, range: [rowStart, columnStart, rowEnd, columnEnd], } = model.getSelectedView(); const row = Math.min(rowStart, rowEnd); const column = Math.min(columnStart, columnEnd); const range = { sheet, row, column, width: Math.abs(columnEnd - columnStart) + 1, height: Math.abs(rowEnd - rowStart) + 1, }; model.pasteCsvText(range, value); setRedrawId((id) => id + 1); } else { // NOT IMPLEMENTED } event.preventDefault(); event.stopPropagation(); }} onCopy={(event: React.ClipboardEvent) => { const data = model.copyToClipboard(); const sheet = model.getSelectedSheet(); // '2024-10-18T14:07:37.599Z' let clipboardId = sessionStorage.getItem( CLIPBOARD_ID_SESSION_STORAGE_KEY, ); if (!clipboardId) { clipboardId = getNewClipboardId(); sessionStorage.setItem(CLIPBOARD_ID_SESSION_STORAGE_KEY, clipboardId); } const sheetData: { [row: number]: { [column: number]: ClipboardCell; }; } = {}; data.data.forEach((value, row) => { const rowData: { [column: number]: ClipboardCell; } = {}; value.forEach((val, column) => { rowData[column] = val; }); sheetData[row] = rowData; }); const clipboardJsonStr = JSON.stringify({ type: "copy", area: data.range, sheetData, sheet, clipboardId, }); event.clipboardData.setData("text/plain", data.csv.trim()); event.clipboardData.setData("application/json", clipboardJsonStr); event.preventDefault(); event.stopPropagation(); }} onCut={(event: React.ClipboardEvent) => { const data = model.copyToClipboard(); const sheet = model.getSelectedSheet(); // '2024-10-18T14:07:37.599Z' let clipboardId = sessionStorage.getItem( CLIPBOARD_ID_SESSION_STORAGE_KEY, ); if (!clipboardId) { clipboardId = getNewClipboardId(); sessionStorage.setItem(CLIPBOARD_ID_SESSION_STORAGE_KEY, clipboardId); } const sheetData: { [row: number]: { [column: number]: ClipboardCell; }; } = {}; data.data.forEach((value, row) => { const rowData: { [column: number]: ClipboardCell; } = {}; value.forEach((val, column) => { rowData[column] = val; }); sheetData[row] = rowData; }); const clipboardJsonStr = JSON.stringify({ type: "cut", area: data.range, sheetData, sheet, clipboardId, }); event.clipboardData.setData("text/plain", data.csv); event.clipboardData.setData("application/json", clipboardJsonStr); workbookState.setCutRange({ sheet: model.getSelectedSheet(), rowStart: data.range[0], rowEnd: data.range[2], columnStart: data.range[1], columnEnd: data.range[3], }); event.preventDefault(); event.stopPropagation(); setRedrawId((id) => id + 1); }} > { const { sheet, range: [rowStart, columnStart, rowEnd, columnEnd], } = model.getSelectedView(); model.rangeClearFormatting( sheet, rowStart, columnStart, rowEnd, columnEnd, ); setRedrawId((id) => id + 1); }} onIncreaseFontSize={(delta: number) => { onIncreaseFontSize(delta); }} onDownloadPNG={() => { // creates a new canvas element in the visible part of the the selected area const worksheetCanvas = worksheetRef.current?.getCanvas(); if (!worksheetCanvas) { return; } const { range: [rowStart, columnStart, rowEnd, columnEnd], } = model.getSelectedView(); // NB: cells outside of the displayed area are not rendered // I think the only reasonable way to do this would be server side. let [x, y] = worksheetCanvas.getCoordinatesByCell( rowStart, columnStart, ); const [x1, y1] = worksheetCanvas.getCoordinatesByCell( rowEnd + 1, columnEnd + 1, ); const width = (x1 - x) * devicePixelRatio; const height = (y1 - y) * devicePixelRatio; x *= devicePixelRatio; y *= devicePixelRatio; const capturedCanvas = document.createElement("canvas"); capturedCanvas.width = width; capturedCanvas.height = height; const ctx = capturedCanvas.getContext("2d"); if (!ctx) { return; } ctx.drawImage( worksheetCanvas.canvas, x, y, width, height, 0, 0, width, height, ); const downloadLink = document.createElement("a"); downloadLink.href = capturedCanvas.toDataURL("image/png"); downloadLink.download = "ironcalc.png"; downloadLink.click(); }} onBorderChanged={(border: BorderOptions): void => { const { sheet, range: [rowStart, columnStart, rowEnd, columnEnd], } = model.getSelectedView(); const row = Math.min(rowStart, rowEnd); const column = Math.min(columnStart, columnEnd); const width = Math.abs(columnEnd - columnStart) + 1; const height = Math.abs(rowEnd - rowStart) + 1; const borderArea = { type: border.border, item: border, }; model.setAreaWithBorder( { sheet, row, column, width, height }, borderArea, ); setRedrawId((id) => id + 1); }} fillColor={style.fill.fg_color || "#FFFFFF"} fontColor={style.font.color} fontSize={style.font.sz} bold={style.font.b} underline={style.font.u} italic={style.font.i} strike={style.font.strike} horizontalAlign={ style.alignment ? style.alignment.horizontal : "general" } verticalAlign={ style.alignment?.vertical ? style.alignment.vertical : "bottom" } wrapText={style.alignment?.wrap_text || false} canEdit={true} numFmt={style.num_fmt} showGridLines={model.getShowGridLines(model.getSelectedSheet())} onToggleShowGridLines={(show) => { const sheet = model.getSelectedSheet(); model.setShowGridLines(sheet, show); setRedrawId((id) => id + 1); }} /> { setRedrawId((id) => id + 1); focusWorkbook(); }} onTextUpdated={() => { setRedrawId((id) => id + 1); }} model={model} workbookState={workbookState} openDrawer={() => { setDrawerOpen(true); }} canEdit={true} /> { setRedrawId((id) => id + 1); }} ref={worksheetRef} /> { if (info[sheet].state !== "visible") { model.unhideSheet(sheet); } model.setSelectedSheet(sheet); setRedrawId((value) => value + 1); }} onAddBlankSheet={(): void => { model.newSheet(); setRedrawId((value) => value + 1); }} onSheetColorChanged={(hex: string): void => { try { model.setSheetColor(model.getSelectedSheet(), hex); setRedrawId((value) => value + 1); } catch (e) { // TODO: Show a proper modal dialog alert(`${e}`); } }} onSheetRenamed={(name: string): void => { try { model.renameSheet(model.getSelectedSheet(), name); setRedrawId((value) => value + 1); } catch (e) { // TODO: Show a proper modal dialog alert(`${e}`); } }} onSheetDeleted={(): void => { const selectedSheet = model.getSelectedSheet(); model.deleteSheet(selectedSheet); setRedrawId((value) => value + 1); }} onHideSheet={(): void => { const selectedSheet = model.getSelectedSheet(); model.hideSheet(selectedSheet); setRedrawId((value) => value + 1); }} /> setDrawerOpen(false)} width={drawerWidth} onWidthChange={setDrawerWidth} model={model} onUpdate={() => { setRedrawId((id) => id + 1); }} getSelectedArea={() => { const worksheetNames = model .getWorksheetsProperties() .map((s) => s.name); const selectedView = model.getSelectedView(); return getFullRangeToString(selectedView, worksheetNames); }} /> ); }; type WorksheetAreaLeftProps = { $drawerWidth: number }; const WorksheetAreaLeft = styled("div")( ({ $drawerWidth }) => ({ position: "absolute", top: `${TOOLBAR_HEIGHT + 1}px`, width: `calc(100% - ${$drawerWidth}px)`, height: `calc(100% - ${TOOLBAR_HEIGHT}px)`, }), ); const Container = styled("div")` display: flex; flex-direction: column; height: 100%; position: relative; font-family: ${({ theme }) => theme.typography.fontFamily}; &:focus { outline: none; } `; export default Workbook;