Files
IronCalc/webapp/IronCalc/src/components/Workbook/Workbook.tsx
2025-12-09 17:15:39 +01:00

785 lines
25 KiB
TypeScript

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<HTMLDivElement | null>(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: <PaintRoller /> and rotated.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paint-roller" style="transform:rotate(-8deg)"><rect width="16" height="6" x="2" y="2" rx="2"></rect><path d="M10 16v-2a2 2 0 0 1 2-2h8a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"></path><rect width="4" height="6" x="8" y="16" rx="1"></rect></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 (
<Container
ref={rootRef}
onKeyDown={onKeyDown}
tabIndex={0}
onClick={(event: React.MouseEvent) => {
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<number, Map<number, ClipboardCell>> = 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);
}}
>
<Toolbar
canUndo={model.canUndo()}
canRedo={model.canRedo()}
onRedo={onRedo}
onUndo={onUndo}
onToggleUnderline={onToggleUnderline}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStrike={onToggleStrike}
onToggleHorizontalAlign={onToggleHorizontalAlign}
onToggleVerticalAlign={onToggleVerticalAlign}
onToggleWrapText={onToggleWrapText}
onCopyStyles={onCopyStyles}
onTextColorPicked={onTextColorPicked}
onFillColorPicked={onFillColorPicked}
onNumberFormatPicked={onNumberFormatPicked}
onClearFormatting={() => {
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);
}}
/>
<WorksheetAreaLeft $drawerWidth={isDrawerOpen ? drawerWidth : 0}>
<FormulaBar
cellAddress={cellAddress()}
formulaValue={formulaValue()}
onChange={() => {
setRedrawId((id) => id + 1);
focusWorkbook();
}}
onTextUpdated={() => {
setRedrawId((id) => id + 1);
}}
model={model}
workbookState={workbookState}
openDrawer={() => {
setDrawerOpen(true);
}}
canEdit={true}
/>
<Worksheet
model={model}
workbookState={workbookState}
refresh={(): void => {
setRedrawId((id) => id + 1);
}}
ref={worksheetRef}
/>
<SheetTabBar
sheets={info}
selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}
onSheetSelected={(sheet: number): void => {
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);
}}
/>
</WorksheetAreaLeft>
<RightDrawer
isOpen={isDrawerOpen}
onClose={() => 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);
}}
/>
</Container>
);
};
type WorksheetAreaLeftProps = { $drawerWidth: number };
const WorksheetAreaLeft = styled("div")<WorksheetAreaLeftProps>(
({ $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;