Compare commits

...

8 Commits

Author SHA1 Message Date
Nicolás Hatcher
6740a43fe6 UPDATE: Add "Export Area to markdown" 2025-06-08 12:40:54 +02:00
Nicolás Hatcher
2b3ae8e20f FIX: Remove some unused dependencies and updated dependencies 2025-06-07 15:32:23 +02:00
Nicolás Hatcher
138a483c65 UPDATE: Double click in the outline handle fills column
This also removes React from the equation.
So all event handling is done outside of the React loop.
This simplifies some things and helps us in a possible move
away from React.

This is closer to how we deal with the column and row handle resizers.

I think it works quite well and it is more future proof.

But TBH I just want to try it out and see what is the DX after this.

Fixes #359
2025-06-07 11:12:10 +02:00
Nicolás Hatcher
2eb9266c30 UPDATE: Factor out a couple of helper functions from the main canvas file 2025-06-07 11:12:10 +02:00
Nicolás Hatcher
b9d3f5329b FIX: There are two fills in every new Excel model
Excel expects two default fills. If a different fill is present Excel
removes it and substitutes it with the defaults.

This resulted in models having the default fill for the first style.

Thanks @scandel!
2025-06-05 06:36:34 +02:00
Nicolás Hatcher
af49d7ad96 FIX: Download to png off by one errors 2025-06-02 21:11:18 +02:00
Nicolás Hatcher
3e015bf13a FIX: control+shitf selects area 2025-06-02 20:59:18 +02:00
Nicolás Hatcher
a5d8ee9ef0 FIX: Download all selected area
We were previously downloading only the bounds of the visible cells,
without taking into account the frozen rows/colums.

Fixes #343
2025-05-17 11:49:42 +02:00
22 changed files with 2383 additions and 2521 deletions

View File

@@ -1931,16 +1931,32 @@ impl Model {
}
/// Returns markup representation of the given `sheet`.
pub fn get_sheet_markup(&self, sheet: u32) -> Result<String, String> {
let worksheet = self.workbook.worksheet(sheet)?;
let dimension = worksheet.dimension();
pub fn get_sheet_markup(
&self,
sheet: u32,
start_row: i32,
start_column: i32,
width: i32,
height: i32,
) -> Result<String, String> {
let mut table: Vec<Vec<String>> = Vec::new();
if start_row < 1 || start_column < 1 {
return Err("Start row and column must be positive".to_string());
}
if start_row + height >= LAST_ROW || start_column + width >= LAST_COLUMN {
return Err("Start row and column exceed the maximum allowed".to_string());
}
if height <= 0 || width <= 0 {
return Err("Height must be positive and width must be positive".to_string());
}
let mut rows = Vec::new();
// a mutable vector to store the column widths of length `width + 1`
let mut column_widths: Vec<f64> = vec![0.0; (width + 1) as usize];
for row in 1..(dimension.max_row + 1) {
for row in start_row..(start_row + height + 1) {
let mut row_markup: Vec<String> = Vec::new();
for column in 1..(dimension.max_column + 1) {
for column in start_column..(start_column + width + 1) {
let mut cell_markup = match self.get_cell_formula(sheet, row, column)? {
Some(formula) => formula,
None => self.get_formatted_cell_value(sheet, row, column)?,
@@ -1949,12 +1965,34 @@ impl Model {
if style.font.b {
cell_markup = format!("**{cell_markup}**")
}
column_widths[(column - start_column) as usize] =
column_widths[(column - start_column) as usize].max(cell_markup.len() as f64);
row_markup.push(cell_markup);
}
rows.push(row_markup.join("|"));
table.push(row_markup);
}
let mut rows = Vec::new();
for (j, row) in table.iter().enumerate() {
if j == 1 {
let mut row_markup = String::new();
for i in 0..(width + 1) {
row_markup.push('|');
let wide = column_widths[i as usize] as usize;
row_markup.push_str(&"-".repeat(wide));
}
rows.push(row_markup);
}
let mut row_markup = String::new();
for (i, cell) in row.iter().enumerate() {
row_markup.push('|');
let wide = column_widths[i] as usize;
// Add padding to the cell content
row_markup.push_str(&format!("{:<wide$}", cell, wide = wide));
}
rows.push(row_markup);
}
Ok(rows.join("\n"))
}

View File

@@ -21,7 +21,7 @@ fn test_sheet_markup() {
model.set_cell_style(0, 4, 1, &style).unwrap();
assert_eq!(
model.get_sheet_markup(0),
model.get_sheet_markup(0, 1, 1, 4, 2),
Ok("**Item**|**Cost**\nRent|$600\nElectricity|$200\n**Total**|=SUM(B2:B3)".to_string()),
)
}

View File

@@ -62,3 +62,17 @@ fn test_create_named_style() {
let style = model.get_style_for_cell(0, 1, 1).unwrap();
assert!(style.font.b);
}
#[test]
fn empty_models_have_two_fills() {
let model = new_empty_model();
assert_eq!(model.workbook.styles.fills.len(), 2);
assert_eq!(
model.workbook.styles.fills[0].pattern_type,
"none".to_string()
);
assert_eq!(
model.workbook.styles.fills[1].pattern_type,
"gray125".to_string()
);
}

View File

@@ -303,7 +303,14 @@ impl Default for Styles {
Styles {
num_fmts: vec![],
fonts: vec![Default::default()],
fills: vec![Default::default()],
fills: vec![
Default::default(),
Fill {
pattern_type: "gray125".to_string(),
fg_color: None,
bg_color: None,
},
],
borders: vec![Default::default()],
cell_style_xfs: vec![Default::default()],
cell_xfs: vec![Default::default()],

View File

@@ -293,6 +293,19 @@ impl UserModel {
self.model.workbook.name = name.to_string();
}
/// Get area markdown
pub fn get_sheet_markup(
&self,
sheet: u32,
row_start: i32,
column_start: i32,
row_end: i32,
column_end: i32,
) -> Result<String, String> {
self.model
.get_sheet_markup(sheet, row_start, column_start, row_end, column_end)
}
/// Undoes last change if any, places the change in the redo list and evaluates the model if needed
///
/// See also:

View File

@@ -672,4 +672,18 @@ impl Model {
.delete_defined_name(name, scope)
.map_err(|e| to_js_error(e.to_string()))
}
#[wasm_bindgen(js_name = "getSheetMarkup")]
pub fn get_sheet_markup(
&self,
sheet: u32,
start_row: i32,
start_column: i32,
end_row: i32,
end_column: i32,
) -> Result<String, JsError> {
self.model
.get_sheet_markup(sheet, start_row, start_column, end_row, end_column)
.map_err(to_js_error)
}
}

View File

@@ -2,11 +2,7 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
addons: [],
framework: {
name: "@storybook/react-vite",
options: {},

File diff suppressed because it is too large Load Diff

View File

@@ -18,31 +18,26 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@ironcalc/wasm": "file:../../bindings/wasm/pkg",
"@mui/material": "^6.4",
"@mui/system": "^6.4",
"i18next": "^23.11.1",
"lucide-react": "^0.473.0",
"@mui/material": "^7.1.1",
"@mui/system": "^7.1.1",
"i18next": "^25.2.1",
"lucide-react": "^0.513.0",
"react-colorful": "^5.6.1",
"react-i18next": "^15.4.0"
"react-i18next": "^15.5.2"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@chromatic-com/storybook": "^3.2.4",
"@storybook/addon-essentials": "^8.6.0",
"@storybook/addon-interactions": "^8.6.0",
"@storybook/blocks": "^8.6.0",
"@storybook/react": "^8.6.0",
"@storybook/react-vite": "^8.6.0",
"@storybook/test": "^8.6.0",
"@storybook/react": "^9.0.5",
"@storybook/react-vite": "^9.0.5",
"@vitejs/plugin-react": "^4.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"storybook": "^8.6.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"storybook": "^9.0.5",
"ts-node": "^10.9.2",
"typescript": "~5.6.2",
"vite": "^6.2.0",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^3.0.7"
"vitest": "^3.2.2"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",

View File

@@ -1,6 +1,6 @@
import "./index.css";
import type { Model } from "@ironcalc/wasm";
import ThemeProvider from "@mui/material/styles/ThemeProvider";
import { ThemeProvider } from "@mui/material";
import Workbook from "./components/Workbook/Workbook.tsx";
import { WorkbookState } from "./components/workbookState.ts";
import { theme } from "./theme.ts";

View File

@@ -40,6 +40,7 @@ import {
ArrowMiddleFromLine,
DecimalPlacesDecreaseIcon,
DecimalPlacesIncreaseIcon,
Markdown,
} from "../../icons";
import { theme } from "../../theme";
import BorderPicker from "../BorderPicker/BorderPicker";
@@ -74,6 +75,7 @@ type ToolbarProperties = {
onClearFormatting: () => void;
onIncreaseFontSize: (delta: number) => void;
onDownloadPNG: () => void;
onCopyMarkdown: () => void;
fillColor: string;
fontColor: string;
fontSize: number;
@@ -429,6 +431,17 @@ function Toolbar(properties: ToolbarProperties) {
>
<ImageDown />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
onClick={() => {
properties.onCopyMarkdown();
}}
disabled={!canEdit}
title={t("toolbar.selected_markdown")}
>
<Markdown />
</StyledButton>
<ColorPicker
color={properties.fontColor}

View File

@@ -558,6 +558,26 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
onIncreaseFontSize={(delta: number) => {
onIncreaseFontSize(delta);
}}
onCopyMarkdown={async () => {
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 markdown = model.getSheetMarkup(
sheet,
row,
column,
width,
height,
);
// Copy to clipboard
// NB: This will not work in non secure contexts or in iframes (i.e storybook)
await navigator.clipboard.writeText(markdown);
}}
onDownloadPNG={() => {
// creates a new canvas element in the visible part of the the selected area
const worksheetCanvas = worksheetRef.current?.getCanvas();
@@ -567,19 +587,15 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
const {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
const { topLeftCell, bottomRightCell } =
worksheetCanvas.getVisibleCells();
const firstRow = Math.max(rowStart, topLeftCell.row);
const firstColumn = Math.max(columnStart, topLeftCell.column);
const lastRow = Math.min(rowEnd, bottomRightCell.row);
const lastColumn = Math.min(columnEnd, bottomRightCell.column);
// 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(
firstRow,
firstColumn,
rowStart,
columnStart,
);
const [x1, y1] = worksheetCanvas.getCoordinatesByCell(
lastRow + 1,
lastColumn + 1,
rowEnd + 1,
columnEnd + 1,
);
const width = (x1 - x) * devicePixelRatio;
const height = (y1 - y) * devicePixelRatio;

View File

@@ -24,7 +24,7 @@ import {
TOOLBAR_HEIGHT,
} from "../constants";
import type { Cell } from "../types";
import { AreaType, type WorkbookState } from "../workbookState";
import type { WorkbookState } from "../workbookState";
import CellContextMenu from "./CellContextMenu";
import usePointer from "./usePointer";
@@ -59,7 +59,6 @@ const Worksheet = forwardRef(
const spacerElement = useRef<HTMLDivElement>(null);
const cellOutline = useRef<HTMLDivElement>(null);
const areaOutline = useRef<HTMLDivElement>(null);
const cellOutlineHandle = useRef<HTMLDivElement>(null);
const extendToOutline = useRef<HTMLDivElement>(null);
const columnResizeGuide = useRef<HTMLDivElement>(null);
const rowResizeGuide = useRef<HTMLDivElement>(null);
@@ -85,7 +84,6 @@ const Worksheet = forwardRef(
const worksheetRef = worksheetElement.current;
const outline = cellOutline.current;
const handle = cellOutlineHandle.current;
const area = areaOutline.current;
const extendTo = extendToOutline.current;
const editor = editorElement.current;
@@ -97,7 +95,6 @@ const Worksheet = forwardRef(
!columnHeadersRef ||
!worksheetRef ||
!outline ||
!handle ||
!area ||
!extendTo ||
!scrollElement.current ||
@@ -118,7 +115,6 @@ const Worksheet = forwardRef(
rowGuide: rowGuideRef,
columnHeaders: columnHeadersRef,
cellOutline: outline,
cellOutlineHandle: handle,
areaOutline: area,
extendToOutline: extendTo,
editor: editor,
@@ -191,203 +187,74 @@ const Worksheet = forwardRef(
worksheetCanvas.current = canvas;
});
const { onPointerMove, onPointerDown, onPointerHandleDown, onPointerUp } =
usePointer({
model,
workbookState,
refresh,
onColumnSelected: (column: number, shift: boolean) => {
let firstColumn = column;
let lastColumn = column;
if (shift) {
const { range } = model.getSelectedView();
firstColumn = Math.min(range[1], column, range[3]);
lastColumn = Math.max(range[3], column, range[1]);
}
model.setSelectedCell(1, firstColumn);
model.setSelectedRange(1, firstColumn, LAST_ROW, lastColumn);
refresh();
},
onRowSelected: (row: number, shift: boolean) => {
let firstRow = row;
let lastRow = row;
if (shift) {
const { range } = model.getSelectedView();
firstRow = Math.min(range[0], row, range[2]);
lastRow = Math.max(range[2], row, range[0]);
}
model.setSelectedCell(firstRow, 1);
model.setSelectedRange(firstRow, 1, lastRow, LAST_COLUMN);
refresh();
},
onAllSheetSelected: () => {
model.setSelectedCell(1, 1);
model.setSelectedRange(1, 1, LAST_ROW, LAST_COLUMN);
},
onCellSelected: (cell: Cell, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
model.setSelectedCell(cell.row, cell.column);
refresh();
},
onAreaSelecting: (cell: Cell) => {
const { onPointerMove, onPointerDown, onPointerUp } = usePointer({
model,
workbookState,
refresh,
onColumnSelected: (column: number, shift: boolean) => {
let firstColumn = column;
let lastColumn = column;
if (shift) {
const { range } = model.getSelectedView();
firstColumn = Math.min(range[1], column, range[3]);
lastColumn = Math.max(range[3], column, range[1]);
}
model.setSelectedCell(1, firstColumn);
model.setSelectedRange(1, firstColumn, LAST_ROW, lastColumn);
refresh();
},
onRowSelected: (row: number, shift: boolean) => {
let firstRow = row;
let lastRow = row;
if (shift) {
const { range } = model.getSelectedView();
firstRow = Math.min(range[0], row, range[2]);
lastRow = Math.max(range[2], row, range[0]);
}
model.setSelectedCell(firstRow, 1);
model.setSelectedRange(firstRow, 1, lastRow, LAST_COLUMN);
refresh();
},
onAllSheetSelected: () => {
model.setSelectedCell(1, 1);
model.setSelectedRange(1, 1, LAST_ROW, LAST_COLUMN);
},
onCellSelected: (cell: Cell, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
model.setSelectedCell(cell.row, cell.column);
refresh();
},
onAreaSelecting: (cell: Cell) => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { row, column } = cell;
model.onAreaSelecting(row, column);
canvas.renderSheet();
refresh();
},
onAreaSelected: () => {
const styles = workbookState.getCopyStyles();
if (styles?.length) {
model.onPasteStyles(styles);
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { row, column } = cell;
model.onAreaSelecting(row, column);
canvas.renderSheet();
refresh();
},
onAreaSelected: () => {
const styles = workbookState.getCopyStyles();
if (styles?.length) {
model.onPasteStyles(styles);
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
canvas.renderSheet();
}
workbookState.setCopyStyles(null);
if (worksheetElement.current) {
worksheetElement.current.style.cursor = "auto";
}
refresh();
},
onExtendToCell: (cell) => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { row, column } = cell;
const {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
// We are either extending by rows or by columns
// And we could be doing it in the positive direction (downwards or right)
// or the negative direction (upwards or left)
if (
row > rowEnd &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < row - rowEnd) ||
(column > columnEnd && column - columnEnd < row - rowEnd))
) {
// rows downwards
const area = {
type: AreaType.rowsDown,
rowStart: rowEnd + 1,
rowEnd: row,
columnStart,
columnEnd,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
row < rowStart &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < rowStart - row) ||
(column > columnEnd && column - columnEnd < rowStart - row))
) {
// rows upwards
const area = {
type: AreaType.rowsUp,
rowStart: row,
rowEnd: rowStart,
columnStart,
columnEnd,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
column > columnEnd &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < column - columnEnd) ||
(row > rowEnd && row - rowEnd < column - columnEnd))
) {
// columns right
const area = {
type: AreaType.columnsRight,
rowStart,
rowEnd,
columnStart: columnEnd + 1,
columnEnd: column,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
} else if (
column < columnStart &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < columnStart - column) ||
(row > rowEnd && row - rowEnd < columnStart - column))
) {
// columns left
const area = {
type: AreaType.columnsLeft,
rowStart,
rowEnd,
columnStart: column,
columnEnd: columnStart,
};
workbookState.setExtendToArea(area);
canvas.renderSheet();
}
},
onExtendToEnd: () => {
const canvas = worksheetCanvas.current;
if (!canvas) {
return;
}
const { sheet, range } = model.getSelectedView();
const extendedArea = workbookState.getExtendToArea();
if (!extendedArea) {
return;
}
const rowStart = Math.min(range[0], range[2]);
const height = Math.abs(range[2] - range[0]) + 1;
const width = Math.abs(range[3] - range[1]) + 1;
const columnStart = Math.min(range[1], range[3]);
const area = {
sheet,
row: rowStart,
column: columnStart,
width,
height,
};
switch (extendedArea.type) {
case AreaType.rowsDown:
model.autoFillRows(area, extendedArea.rowEnd);
break;
case AreaType.rowsUp: {
model.autoFillRows(area, extendedArea.rowStart);
break;
}
case AreaType.columnsRight: {
model.autoFillColumns(area, extendedArea.columnEnd);
break;
}
case AreaType.columnsLeft: {
model.autoFillColumns(area, extendedArea.columnStart);
break;
}
}
model.setSelectedRange(
Math.min(rowStart, extendedArea.rowStart),
Math.min(columnStart, extendedArea.columnStart),
Math.max(rowStart + height - 1, extendedArea.rowEnd),
Math.max(columnStart + width - 1, extendedArea.columnEnd),
);
workbookState.clearExtendToArea();
canvas.renderSheet();
},
canvasElement,
worksheetElement,
worksheetCanvas,
});
}
workbookState.setCopyStyles(null);
if (worksheetElement.current) {
worksheetElement.current.style.cursor = "auto";
}
refresh();
},
canvasElement,
worksheetElement,
worksheetCanvas,
});
const onScroll = (): void => {
if (!scrollElement.current || !worksheetCanvas.current) {
@@ -463,10 +330,6 @@ const Worksheet = forwardRef(
</EditorWrapper>
<AreaOutline ref={areaOutline} />
<ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle
ref={cellOutlineHandle}
onPointerDown={onPointerHandleDown}
/>
<ColumnResizeGuide ref={columnResizeGuide} />
<RowResizeGuide ref={rowResizeGuide} />
<ColumnHeaders ref={columnHeaders} />
@@ -640,15 +503,6 @@ const CellOutline = styled("div")`
display: flex;
`;
const CellOutlineHandle = styled("div")`
position: absolute;
width: 5px;
height: 5px;
background: ${outlineColor};
cursor: crosshair;
border-radius: 1px;
`;
const ExtendToOutline = styled("div")`
position: absolute;
border: 1px dashed ${outlineColor};

View File

@@ -20,8 +20,6 @@ interface PointerSettings {
onAllSheetSelected: () => void;
onAreaSelecting: (cell: Cell) => void;
onAreaSelected: () => void;
onExtendToCell: (cell: Cell) => void;
onExtendToEnd: () => void;
model: Model;
workbookState: WorkbookState;
refresh: () => void;
@@ -31,12 +29,10 @@ interface PointerEvents {
onPointerDown: (event: PointerEvent) => void;
onPointerMove: (event: PointerEvent) => void;
onPointerUp: (event: PointerEvent) => void;
onPointerHandleDown: (event: PointerEvent) => void;
}
const usePointer = (options: PointerSettings): PointerEvents => {
const isSelecting = useRef(false);
const isExtending = useRef(false);
const isInsertingRef = useRef(false);
const onPointerMove = useCallback(
@@ -47,9 +43,7 @@ const usePointer = (options: PointerSettings): PointerEvents => {
return;
}
if (
!(isSelecting.current || isExtending.current || isInsertingRef.current)
) {
if (!(isSelecting.current || isInsertingRef.current)) {
return;
}
const { canvasElement, model, worksheetCanvas } = options;
@@ -70,8 +64,6 @@ const usePointer = (options: PointerSettings): PointerEvents => {
if (isSelecting.current) {
options.onAreaSelecting(cell);
} else if (isExtending.current) {
options.onExtendToCell(cell);
} else if (isInsertingRef.current) {
const { refresh, workbookState } = options;
const editingCell = workbookState.getEditingCell();
@@ -103,11 +95,6 @@ const usePointer = (options: PointerSettings): PointerEvents => {
isSelecting.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onAreaSelected();
} else if (isExtending.current) {
const { worksheetElement } = options;
isExtending.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onExtendToEnd();
} else if (isInsertingRef.current) {
const { worksheetElement } = options;
isInsertingRef.current = false;
@@ -120,10 +107,14 @@ const usePointer = (options: PointerSettings): PointerEvents => {
const onPointerDown = useCallback(
(event: PointerEvent) => {
const target = event.target as HTMLElement;
if (target !== null && target.className === "column-resize-handle") {
if (target.className === "column-resize-handle") {
// we are resizing a column
return;
}
if (target.className.includes("ironcalc-cell-handle")) {
// we are extending values
return;
}
let x = event.clientX;
let y = event.clientY;
const {
@@ -236,34 +227,25 @@ const usePointer = (options: PointerSettings): PointerEvents => {
);
// we continue to select the new cell
}
options.onCellSelected(cell, event);
isSelecting.current = true;
worksheetWrapper.setPointerCapture(event.pointerId);
if (event.shiftKey) {
// We are extending the selection
options.onAreaSelecting(cell);
options.onAreaSelected();
} else {
// We are selecting a single cell
options.onCellSelected(cell, event);
isSelecting.current = true;
worksheetWrapper.setPointerCapture(event.pointerId);
}
}
},
[options],
);
const onPointerHandleDown = useCallback(
(event: PointerEvent) => {
const worksheetWrapper = options.worksheetElement.current;
// Silence the linter
if (!worksheetWrapper) {
return;
}
isExtending.current = true;
worksheetWrapper.setPointerCapture(event.pointerId);
event.stopPropagation();
event.preventDefault();
},
[options],
);
return {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerHandleDown,
};
};

View File

@@ -0,0 +1,211 @@
import { AreaType } from "../workbookState";
import { LAST_COLUMN, LAST_ROW, outlineColor } from "./constants";
import type WorksheetCanvas from "./worksheetCanvas";
export function attachOutlineHandle(
worksheet: WorksheetCanvas,
): HTMLDivElement {
// There is *always* a parent
const parent = worksheet.canvas.parentElement as HTMLDivElement;
// Remove any existing cell outline handles
for (const handle of parent.querySelectorAll(".ironcalc-cell-handle")) {
handle.remove();
}
// Create a new cell outline handle
const cellOutlineHandle = document.createElement("div");
cellOutlineHandle.className = "ironcalc-cell-handle";
parent.appendChild(cellOutlineHandle);
worksheet.cellOutlineHandle = cellOutlineHandle;
Object.assign(cellOutlineHandle.style, {
position: "absolute",
width: "5px",
height: "5px",
background: outlineColor,
cursor: "crosshair",
borderRadius: "1px",
});
// cell handle events
const resizeHandleMove = (event: MouseEvent): void => {
const canvasRect = worksheet.canvas.getBoundingClientRect();
const x = event.clientX - canvasRect.x;
const y = event.clientY - canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (!cell) {
return;
}
const { row, column } = cell;
const {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = worksheet.model.getSelectedView();
// We are either extending by rows or by columns
// And we could be doing it in the positive direction (downwards or right)
// or the negative direction (upwards or left)
if (
row > rowEnd &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < row - rowEnd) ||
(column > columnEnd && column - columnEnd < row - rowEnd))
) {
// rows downwards
const area = {
type: AreaType.rowsDown,
rowStart: rowEnd + 1,
rowEnd: row,
columnStart,
columnEnd,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
row < rowStart &&
((column <= columnEnd && column >= columnStart) ||
(column < columnStart && columnStart - column < rowStart - row) ||
(column > columnEnd && column - columnEnd < rowStart - row))
) {
// rows upwards
const area = {
type: AreaType.rowsUp,
rowStart: row,
rowEnd: rowStart,
columnStart,
columnEnd,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
column > columnEnd &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < column - columnEnd) ||
(row > rowEnd && row - rowEnd < column - columnEnd))
) {
// columns right
const area = {
type: AreaType.columnsRight,
rowStart,
rowEnd,
columnStart: columnEnd + 1,
columnEnd: column,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
} else if (
column < columnStart &&
((row <= rowEnd && row >= rowStart) ||
(row < rowStart && rowStart - row < columnStart - column) ||
(row > rowEnd && row - rowEnd < columnStart - column))
) {
// columns left
const area = {
type: AreaType.columnsLeft,
rowStart,
rowEnd,
columnStart: column,
columnEnd: columnStart,
};
worksheet.workbookState.setExtendToArea(area);
worksheet.renderSheet();
}
};
const resizeHandleUp = (_event: MouseEvent): void => {
document.removeEventListener("pointermove", resizeHandleMove);
document.removeEventListener("pointerup", resizeHandleUp);
const { sheet, range } = worksheet.model.getSelectedView();
const extendedArea = worksheet.workbookState.getExtendToArea();
if (!extendedArea) {
return;
}
const rowStart = Math.min(range[0], range[2]);
const height = Math.abs(range[2] - range[0]) + 1;
const width = Math.abs(range[3] - range[1]) + 1;
const columnStart = Math.min(range[1], range[3]);
const area = {
sheet,
row: rowStart,
column: columnStart,
width,
height,
};
switch (extendedArea.type) {
case AreaType.rowsDown:
worksheet.model.autoFillRows(area, extendedArea.rowEnd);
break;
case AreaType.rowsUp: {
worksheet.model.autoFillRows(area, extendedArea.rowStart);
break;
}
case AreaType.columnsRight: {
worksheet.model.autoFillColumns(area, extendedArea.columnEnd);
break;
}
case AreaType.columnsLeft: {
worksheet.model.autoFillColumns(area, extendedArea.columnStart);
break;
}
}
worksheet.model.setSelectedRange(
Math.min(rowStart, extendedArea.rowStart),
Math.min(columnStart, extendedArea.columnStart),
Math.max(rowStart + height - 1, extendedArea.rowEnd),
Math.max(columnStart + width - 1, extendedArea.columnEnd),
);
worksheet.workbookState.clearExtendToArea();
worksheet.renderSheet();
};
cellOutlineHandle.addEventListener("pointerdown", () => {
document.addEventListener("pointermove", resizeHandleMove);
document.addEventListener("pointerup", resizeHandleUp);
});
cellOutlineHandle.addEventListener("dblclick", (event) => {
// On double-click, we will auto-fill the rows below the selected cell
const [sheet, row, column] = worksheet.model.getSelectedCell();
let lastUsedRow = row + 1;
let testColumn = column - 1;
// The "test column" is the column to the left of the selected cell or the next column if the left one is empty
if (
testColumn < 1 ||
worksheet.model.getFormattedCellValue(sheet, row, column - 1) === ""
) {
testColumn = column + 1;
if (
testColumn > LAST_COLUMN ||
worksheet.model.getFormattedCellValue(sheet, row, testColumn) === ""
) {
return;
}
}
// Find the last used row in the "test column"
for (let r = row + 1; r <= LAST_ROW; r += 1) {
if (worksheet.model.getFormattedCellValue(sheet, r, testColumn) === "") {
break;
}
lastUsedRow = r;
}
const area = {
sheet,
row: row,
column: column,
width: 1,
height: 1,
};
worksheet.model.autoFillRows(area, lastUsedRow);
event.stopPropagation();
worksheet.renderSheet();
});
return cellOutlineHandle;
}

View File

@@ -0,0 +1,63 @@
// Get a 10% transparency of an hex color
export 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})`;
}
/**
* Splits the given text into multiple lines. If `wrapText` is true, it applies word-wrapping
* based on the specified canvas context, maximum width, and horizontal padding.
*
* - First, the text is split by newline characters so that explicit newlines are respected.
* - If wrapping is enabled, each line is further split into words and measured against the
* available width. Whenever adding an extra word would exceed
* this limit, a new line is started.
*
* @param text The text to split into lines.
* @param wrapText Whether to apply word-wrapping or just return text split by newlines.
* @param context The `CanvasRenderingContext2D` used for measuring text width.
* @param width The maximum width for each line.
* @returns An array of lines (strings), each fitting within the specified width if wrapping is enabled.
*/
export function computeWrappedLines(
text: string,
wrapText: boolean,
context: CanvasRenderingContext2D,
width: number,
): string[] {
// Split the text into lines
const rawLines = text.split("\n");
if (!wrapText) {
// If there is no wrapping, return the raw lines
return rawLines;
}
const wrappedLines = [];
for (const line of rawLines) {
const words = line.split(" ");
let currentLine = words[0];
for (let i = 1; i < words.length; i += 1) {
const word = words[i];
const testLine = `${currentLine} ${word}`;
const textWidth = context.measureText(testLine).width;
if (textWidth < width) {
currentLine = testLine;
} else {
wrappedLines.push(currentLine);
currentLine = word;
}
}
wrappedLines.push(currentLine);
}
return wrappedLines;
}

View File

@@ -18,6 +18,8 @@ import {
headerTextColor,
outlineColor,
} from "./constants";
import { attachOutlineHandle } from "./outlineHandle";
import { computeWrappedLines, hexToRGBA10Percent } from "./util";
export interface CanvasSettings {
model: Model;
@@ -28,7 +30,6 @@ export interface CanvasSettings {
canvas: HTMLCanvasElement;
cellOutline: HTMLDivElement;
areaOutline: HTMLDivElement;
cellOutlineHandle: HTMLDivElement;
extendToOutline: HTMLDivElement;
columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement;
@@ -53,70 +54,6 @@ 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})`;
}
/**
* Splits the given text into multiple lines. If `wrapText` is true, it applies word-wrapping
* based on the specified canvas context, maximum width, and horizontal padding.
*
* - First, the text is split by newline characters so that explicit newlines are respected.
* - If wrapping is enabled, each line is further split into words and measured against the
* available width. Whenever adding an extra word would exceed
* this limit, a new line is started.
*
* @param text The text to split into lines.
* @param wrapText Whether to apply word-wrapping or just return text split by newlines.
* @param context The `CanvasRenderingContext2D` used for measuring text width.
* @param width The maximum width for each line.
* @returns An array of lines (strings), each fitting within the specified width if wrapping is enabled.
*/
function computeWrappedLines(
text: string,
wrapText: boolean,
context: CanvasRenderingContext2D,
width: number,
): string[] {
// Split the text into lines
const rawLines = text.split("\n");
if (!wrapText) {
// If there is no wrapping, return the raw lines
return rawLines;
}
const wrappedLines = [];
for (const line of rawLines) {
const words = line.split(" ");
let currentLine = words[0];
for (let i = 1; i < words.length; i += 1) {
const word = words[i];
const testLine = `${currentLine} ${word}`;
const textWidth = context.measureText(testLine).width;
if (textWidth < width) {
currentLine = testLine;
} else {
wrappedLines.push(currentLine);
currentLine = word;
}
}
wrappedLines.push(currentLine);
}
return wrappedLines;
}
export default class WorksheetCanvas {
sheetWidth: number;
@@ -169,7 +106,6 @@ export default class WorksheetCanvas {
this.refresh = options.refresh;
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;
@@ -179,6 +115,7 @@ export default class WorksheetCanvas {
this.onColumnWidthChanges = options.onColumnWidthChanges;
this.onRowHeightChanges = options.onRowHeightChanges;
this.resetHeaders();
this.cellOutlineHandle = attachOutlineHandle(this);
}
setScrollPosition(scrollPosition: { left: number; top: number }): void {

View File

@@ -19,6 +19,7 @@ import InsertColumnLeftIcon from "./insert-column-left.svg?react";
import InsertColumnRightIcon from "./insert-column-right.svg?react";
import InsertRowAboveIcon from "./insert-row-above.svg?react";
import InsertRowBelow from "./insert-row-below.svg?react";
import Markdown from "./markdown.svg?react";
import IronCalcIcon from "./ironcalc_icon.svg?react";
import IronCalcLogo from "./orange+black.svg?react";
@@ -48,4 +49,5 @@ export {
IronCalcIcon,
IronCalcLogo,
Fx,
Markdown,
};

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path fill-rule="nonzero" d="M3 3h18a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v14h16V5H4zm3 10.5H5v-7h2l2 2 2-2h2v7h-2v-4l-2 2-2-2v4zm11-3h2l-3 3-3-3h2v-4h2v4z"/>
</g>

After

Width:  |  Height:  |  Size: 477 B

View File

@@ -26,6 +26,7 @@
"vertical_align_middle": " Align middle",
"vertical_align_top": "Align top",
"selected_png": "Export Selected area as PNG",
"selected_markdown": "Export Selected area as Markdown",
"wrap_text": "Wrap text",
"format_menu": {
"auto": "Auto",

File diff suppressed because it is too large Load Diff

View File

@@ -14,19 +14,19 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@ironcalc/workbook": "file:../../IronCalc/",
"@mui/material": "^6.4",
"lucide-react": "^0.473.0",
"@mui/material": "^7.1.1",
"lucide-react": "^0.513.0",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.5",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vite-plugin-svgr": "^4.2.0"
}
}