Compare commits

...

13 Commits

Author SHA1 Message Date
Nicolás Hatcher
5c13f241c6 FIX: Fixes for the CI builds 2025-02-28 12:00:54 +01:00
Nicolás Hatcher
26b20eea43 UPDATE: Bump versions to 0.5 2025-02-28 01:00:50 +01:00
Nicolás Hatcher
b62256963a UPDATE: Adds wrapping! 2025-02-28 00:29:44 +01:00
Nicolás Hatcher
4f627b4363 FIX: More sensible decrease/increase font-size 2025-02-28 00:29:44 +01:00
Daniel
a9a8c4f615 UPDATE: Add a dialog when 'Share' buttons is clickled 2025-02-27 18:13:20 +01:00
Nicolás Hatcher
f9c9467e6c FIX: Correct height/width of cells with different font sizes 2025-02-26 23:44:08 +01:00
Nicolás Hatcher
409b77c210 FIX: Default size should be 13 pixels 2025-02-26 20:29:36 +01:00
Nicolás Hatcher
eecf6f3c3b UPDATE: Download to PNG the visible part of the selected area
This downloads only the visible part of the selected area.
To download the full selected area we would need to work a bit more
2025-02-26 19:27:56 +01:00
Nicolás Hatcher
ce7318840d UPDATE: We can now change the font size! 2025-02-26 19:11:38 +01:00
Nicolás Hatcher
7bc563ef29 FIX: Make biome happy 2025-02-26 18:03:15 +01:00
Nicolás Hatcher
8ed88e1445 FIX: Update versions in widget 2025-02-26 18:03:15 +01:00
Nicolás Hatcher
a1353e0817 FIX: More consistent naming conventions in widget 2025-02-26 18:03:15 +01:00
Nicolás Hatcher
c0fa55c5f7 FIX: Add "Apply" button to color picker 2025-02-24 19:00:05 +01:00
44 changed files with 2290 additions and 2325 deletions

View File

@@ -32,7 +32,7 @@ jobs:
manylinux: auto manylinux: auto
working-directory: bindings/python working-directory: bindings/python
- name: Upload wheels - name: Upload wheels
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: wheels name: wheels
path: bindings/python/dist path: bindings/python/dist
@@ -56,7 +56,7 @@ jobs:
sccache: 'true' sccache: 'true'
working-directory: bindings/python working-directory: bindings/python
- name: Upload wheels - name: Upload wheels
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: wheels name: wheels
path: bindings/python/dist path: bindings/python/dist
@@ -79,7 +79,7 @@ jobs:
sccache: 'true' sccache: 'true'
working-directory: bindings/python working-directory: bindings/python
- name: Upload wheels - name: Upload wheels
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: wheels name: wheels
path: bindings/python/dist path: bindings/python/dist
@@ -95,7 +95,7 @@ jobs:
args: --out dist args: --out dist
working-directory: bindings/python working-directory: bindings/python
- name: Upload sdist - name: Upload sdist
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: wheels name: wheels
path: bindings/python/dist path: bindings/python/dist

10
Cargo.lock generated
View File

@@ -414,7 +414,7 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc" name = "ironcalc"
version = "0.3.0" version = "0.5.0"
dependencies = [ dependencies = [
"bitcode", "bitcode",
"chrono", "chrono",
@@ -430,7 +430,7 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc_base" name = "ironcalc_base"
version = "0.3.0" version = "0.5.0"
dependencies = [ dependencies = [
"bitcode", "bitcode",
"chrono", "chrono",
@@ -448,7 +448,7 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc_nodejs" name = "ironcalc_nodejs"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"ironcalc", "ironcalc",
"napi", "napi",
@@ -784,7 +784,7 @@ dependencies = [
[[package]] [[package]]
name = "pyroncalc" name = "pyroncalc"
version = "0.3.0" version = "0.5.0"
dependencies = [ dependencies = [
"ironcalc", "ironcalc",
"pyo3", "pyo3",
@@ -1070,7 +1070,7 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "wasm" name = "wasm"
version = "0.3.2" version = "0.5.0"
dependencies = [ dependencies = [
"ironcalc_base", "ironcalc_base",
"serde", "serde",

View File

@@ -77,7 +77,7 @@ And visit <http://0.0.0.0:8000/ironcalc/>
Add the dependency to `Cargo.toml`: Add the dependency to `Cargo.toml`:
```toml ```toml
[dependencies] [dependencies]
ironcalc = { git = "https://github.com/ironcalc/IronCalc", version = "0.1"} ironcalc = { git = "https://github.com/ironcalc/IronCalc", version = "0.5"}
``` ```
And then use this code in `main.rs`: And then use this code in `main.rs`:

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironcalc_base" name = "ironcalc_base"
version = "0.3.0" version = "0.5.0"
authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"]
edition = "2021" edition = "2021"
homepage = "https://www.ironcalc.com" homepage = "https://www.ironcalc.com"

View File

@@ -170,7 +170,7 @@ fn row_heigh_increases_automatically() {
model model
.set_user_input(0, 1, 1, "My home in Canada had horses\nAnd monkeys!") .set_user_input(0, 1, 1, "My home in Canada had horses\nAnd monkeys!")
.unwrap(); .unwrap();
assert_eq!(model.get_row_height(0, 1), Ok(2.0 * DEFAULT_ROW_HEIGHT)); assert_eq!(model.get_row_height(0, 1), Ok(40.5));
} }
#[test] #[test]

View File

@@ -407,7 +407,7 @@ impl Default for Font {
u: false, u: false,
b: false, b: false,
i: false, i: false,
sz: 11, sz: 13,
color: Some("#000000".to_string()), color: Some("#000000".to_string()),
name: "Calibri".to_string(), name: "Calibri".to_string(),
family: 2, family: 2,

View File

@@ -6,7 +6,7 @@ use csv::{ReaderBuilder, WriterBuilder};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
constants::{self, DEFAULT_ROW_HEIGHT, LAST_COLUMN, LAST_ROW}, constants::{self, LAST_COLUMN, LAST_ROW},
expressions::{ expressions::{
types::{Area, CellReferenceIndex}, types::{Area, CellReferenceIndex},
utils::{is_valid_column_number, is_valid_row}, utils::{is_valid_column_number, is_valid_row},
@@ -127,6 +127,17 @@ fn update_style(old_value: &Style, style_path: &str, value: &str) -> Result<Styl
"font.color" => { "font.color" => {
style.font.color = color(value)?; style.font.color = color(value)?;
} }
"font.size_delta" => {
// This is a special case, we need to add the value to the current size
let size_delta: i32 = value
.parse()
.map_err(|_| format!("Invalid value for font size: '{value}'."))?;
let new_size = style.font.sz + size_delta;
if new_size < 1 {
return Err(format!("Invalid value for font size: '{new_size}'."));
}
style.font.sz = new_size;
}
"fill.bg_color" => { "fill.bg_color" => {
style.fill.bg_color = color(value)?; style.fill.bg_color = color(value)?;
style.fill.pattern_type = "solid".to_string(); style.fill.pattern_type = "solid".to_string();
@@ -419,10 +430,14 @@ impl UserModel {
new_value: value.to_string(), new_value: value.to_string(),
old_value: Box::new(old_value), old_value: Box::new(old_value),
}]; }];
let style = self.model.get_style_for_cell(sheet, row, column)?;
let line_count = value.split('\n').count(); let line_count = value.split('\n').count() as f64;
let row_height = self.model.get_row_height(sheet, row)?; let row_height = self.model.get_row_height(sheet, row)?;
let cell_height = (line_count as f64) * DEFAULT_ROW_HEIGHT; // This is in sync with the front-end auto fit row
let font_size = style.font.sz as f64;
let line_height = font_size * 1.5;
let cell_height = (line_count - 1.0) * line_height + 8.0 + font_size;
if cell_height > row_height { if cell_height > row_height {
diff_list.push(Diff::SetRowHeight { diff_list.push(Diff::SetRowHeight {
sheet, sheet,

View File

@@ -1,7 +1,7 @@
[package] [package]
edition = "2021" edition = "2021"
name = "ironcalc_nodejs" name = "ironcalc_nodejs"
version = "0.3.1" version = "0.5.0"
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib"]
@@ -10,7 +10,7 @@ crate-type = ["cdylib"]
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "2.12.2", default-features = false, features = ["napi4", "serde-json"] } napi = { version = "2.12.2", default-features = false, features = ["napi4", "serde-json"] }
napi-derive = "2.12.2" napi-derive = "2.12.2"
ironcalc = { path = "../../xlsx", version = "0.3.0" } ironcalc = { path = "../../xlsx", version = "0.5.0" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
[build-dependencies] [build-dependencies]

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ironcalc/nodejs", "name": "@ironcalc/nodejs",
"version": "0.3.1", "version": "0.5.1",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"napi": { "napi": {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "pyroncalc" name = "pyroncalc"
version = "0.3.0" version = "0.5.0"
edition = "2021" edition = "2021"
@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
xlsx = { package= "ironcalc", path = "../../xlsx", version = "0.3.0" } xlsx = { package= "ironcalc", path = "../../xlsx", version = "0.5.0" }
pyo3 = { version = "0.23", features = ["extension-module"] } pyo3 = { version = "0.23", features = ["extension-module"] }

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "ironcalc" name = "ironcalc"
version = "0.3.0" version = "0.5.0"
description = "Create, edit and evaluate Excel spreadsheets" description = "Create, edit and evaluate Excel spreadsheets"
requires-python = ">=3.10" requires-python = ">=3.10"
keywords = [ keywords = [

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "wasm" name = "wasm"
version = "0.3.2" version = "0.5.0"
authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"]
description = "IronCalc Web bindings" description = "IronCalc Web bindings"
license = "MIT/Apache-2.0" license = "MIT/Apache-2.0"
@@ -14,7 +14,7 @@ crate-type = ["cdylib"]
# Uses `../ironcalc/base` when used locally, and uses # Uses `../ironcalc/base` when used locally, and uses
# the inicated version from crates.io when published. # the inicated version from crates.io when published.
# https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations
ironcalc_base = { path = "../../base", version = "0.3", features = ["use_regex_lite"] } ironcalc_base = { path = "../../base", version = "0.5", features = ["use_regex_lite"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = "0.2.92" wasm-bindgen = "0.2.92"
serde-wasm-bindgen = "0.4" serde-wasm-bindgen = "0.4"

View File

@@ -49,7 +49,7 @@ test('Styles work', () => {
num_fmt: 'general', num_fmt: 'general',
fill: { pattern_type: 'none' }, fill: { pattern_type: 'none' },
font: { font: {
sz: 11, sz: 13,
color: '#000000', color: '#000000',
name: 'Calibri', name: 'Calibri',
family: 2, family: 2,
@@ -64,7 +64,7 @@ test('Styles work', () => {
num_fmt: 'general', num_fmt: 'general',
fill: { pattern_type: 'none' }, fill: { pattern_type: 'none' },
font: { font: {
sz: 11, sz: 13,
color: '#000000', color: '#000000',
name: 'Calibri', name: 'Calibri',
family: 2, family: 2,

File diff suppressed because it is too large Load Diff

View File

@@ -28,21 +28,21 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@chromatic-com/storybook": "^3.2.4", "@chromatic-com/storybook": "^3.2.4",
"@storybook/addon-essentials": "^8.5.3", "@storybook/addon-essentials": "^8.6.0",
"@storybook/addon-interactions": "^8.5.3", "@storybook/addon-interactions": "^8.6.0",
"@storybook/blocks": "^8.5.3", "@storybook/blocks": "^8.6.0",
"@storybook/react": "^8.5.3", "@storybook/react": "^8.6.0",
"@storybook/react-vite": "^8.5.3", "@storybook/react-vite": "^8.6.0",
"@storybook/test": "^8.5.3", "@storybook/test": "^8.6.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"storybook": "^8.5.3", "storybook": "^8.6.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5", "vite": "^6.2.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vitest": "^2.0.5" "vitest": "^3.0.7"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0", "@types/react": "^18.0.0 || ^19.0.0",

View File

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

View File

@@ -20,9 +20,9 @@ import {
BorderRightIcon, BorderRightIcon,
BorderStyleIcon, BorderStyleIcon,
BorderTopIcon, BorderTopIcon,
} from "../icons"; } from "../../icons";
import { theme } from "../theme"; import { theme } from "../../theme";
import ColorPicker from "./colorPicker"; import ColorPicker from "../ColorPicker/ColorPicker";
type BorderPickerProps = { type BorderPickerProps = {
className?: string; className?: string;

View File

@@ -1,12 +1,13 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import Popover, { type PopoverOrigin } from "@mui/material/Popover"; import Popover, { type PopoverOrigin } from "@mui/material/Popover";
import { Check } from "lucide-react";
import type React from "react"; import type React from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { HexColorInput, HexColorPicker } from "react-colorful"; import { HexColorInput, HexColorPicker } from "react-colorful";
import { theme } from "../theme"; import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
type ColorPickerProps = { type ColorPickerProps = {
className?: string;
color: string; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
onClose: () => void; onClose: () => void;
@@ -17,17 +18,19 @@ type ColorPickerProps = {
}; };
const colorPickerWidth = 240; const colorPickerWidth = 240;
const colorfulHeight = 185; // 150 + 15 + 20 const colorfulHeight = 240;
const ColorPicker = (properties: ColorPickerProps) => { const ColorPicker = (properties: ColorPickerProps) => {
const [color, setColor] = useState<string>(properties.color); const [color, setColor] = useState<string>(properties.color);
const recentColors = useRef<string[]>([]); const recentColors = useRef<string[]>([]);
const { t } = useTranslation();
const closePicker = (newColor: string): void => { const closePicker = (newColor: string): void => {
const maxRecentColors = 14; const maxRecentColors = 14;
properties.onChange(newColor);
const colors = recentColors.current.filter((c) => c !== newColor); const colors = recentColors.current.filter((c) => c !== newColor);
recentColors.current = [newColor, ...colors].slice(0, maxRecentColors); recentColors.current = [newColor, ...colors].slice(0, maxRecentColors);
properties.onChange(newColor);
}; };
const handleClose = (): void => { const handleClose = (): void => {
@@ -85,21 +88,16 @@ const ColorPicker = (properties: ColorPickerProps) => {
/> />
</HexColorInputBox> </HexColorInputBox>
</HexWrapper> </HexWrapper>
<Swatch <Swatch $color={color} />
$color={color}
onClick={(): void => {
closePicker(color);
}}
/>
</ColorPickerInput> </ColorPickerInput>
<HorizontalDivider /> <HorizontalDivider />
<ColorList> <ColorList>
{presetColors.map((presetColor) => ( {presetColors.map((presetColor) => (
<Button <RecentColorButton
key={presetColor} key={presetColor}
$color={presetColor} $color={presetColor}
onClick={(): void => { onClick={(): void => {
closePicker(presetColor); setColor(presetColor);
}} }}
/> />
))} ))}
@@ -111,11 +109,11 @@ const ColorPicker = (properties: ColorPickerProps) => {
<RecentLabel>{"Recent"}</RecentLabel> <RecentLabel>{"Recent"}</RecentLabel>
<ColorList> <ColorList>
{recentColors.current.map((recentColor) => ( {recentColors.current.map((recentColor) => (
<Button <RecentColorButton
key={recentColor} key={recentColor}
$color={recentColor} $color={recentColor}
onClick={(): void => { onClick={(): void => {
closePicker(recentColor); setColor(recentColor);
}} }}
/> />
))} ))}
@@ -124,11 +122,46 @@ const ColorPicker = (properties: ColorPickerProps) => {
) : ( ) : (
<div /> <div />
)} )}
<Buttons>
<StyledButton
onClick={(): void => {
closePicker(color);
}}
>
<Check
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
{t("color_picker.apply")}
</StyledButton>
</Buttons>
</ColorPickerDialog> </ColorPickerDialog>
</Popover> </Popover>
); );
}; };
const Buttons = styled.div`
display: flex;
justify-content: flex-end;
margin: 8px;
`;
const StyledButton = styled("div")`
cursor: pointer;
color: #ffffff;
background: #f2994a;
padding: 0px 10px;
height: 36px;
line-height: 36px;
border-radius: 4px;
display: flex;
align-items: center;
font-family: "Inter";
font-size: 14px;
&:hover {
background: #d68742;
}
`;
const RecentLabel = styled.div` const RecentLabel = styled.div`
font-family: "Inter"; font-family: "Inter";
font-size: 12px; font-size: 12px;
@@ -146,7 +179,7 @@ const ColorList = styled.div`
gap: 4.7px; gap: 4.7px;
`; `;
const Button = styled.button<{ $color: string }>` const RecentColorButton = styled.button<{ $color: string }>`
width: 16px; width: 16px;
height: 16px; height: 16px;
${({ $color }): string => { ${({ $color }): string => {
@@ -174,20 +207,6 @@ const HorizontalDivider = styled.div`
border-top: 1px solid ${theme.palette.grey["200"]}; border-top: 1px solid ${theme.palette.grey["200"]};
`; `;
// const StyledPopover = styled(Popover)`
// .MuiPopover-paper {
// border-radius: 10px;
// border: 0px solid ${theme.palette.background.default};
// box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5);
// }
// .MuiPopover-padding {
// padding: 0px;
// }
// .MuiList-padding {
// padding: 0px;
// }
// `;
const ColorPickerDialog = styled.div` const ColorPickerDialog = styled.div`
background: ${theme.palette.background.default}; background: ${theme.palette.background.default};
width: ${colorPickerWidth}px; width: ${colorPickerWidth}px;

View File

@@ -1,7 +1,7 @@
import { Menu, MenuItem, styled } from "@mui/material"; import { Menu, MenuItem, styled } from "@mui/material";
import { type ComponentProps, useCallback, useRef, useState } from "react"; import { type ComponentProps, useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FormatPicker from "./formatPicker"; import FormatPicker from "./FormatPicker";
import { NumberFormats } from "./formatUtil"; import { NumberFormats } from "./formatUtil";
type FormatMenuProps = { type FormatMenuProps = {

View File

@@ -3,7 +3,7 @@ import { Dialog, TextField } from "@mui/material";
import { Check, X } from "lucide-react"; import { Check, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { theme } from "../theme"; import { theme } from "../../theme";
type FormatPickerProps = { type FormatPickerProps = {
className?: string; className?: string;

View File

@@ -1,14 +1,14 @@
import type { Model } from "@ironcalc/wasm"; import type { Model } from "@ironcalc/wasm";
import { styled } from "@mui/material"; import { styled } from "@mui/material";
import { Fx } from "../icons"; import { Fx } from "../../icons";
import { theme } from "../theme"; import { theme } from "../../theme";
import Editor from "../Editor/Editor";
import { import {
COLUMN_WIDTH_SCALE, COLUMN_WIDTH_SCALE,
ROW_HEIGH_SCALE, ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants"; } from "../WorksheetCanvas/constants";
import { FORMULA_BAR_HEIGHT } from "./constants"; import { FORMULA_BAR_HEIGHT } from "../constants";
import Editor from "./editor/editor"; import type { WorkbookState } from "../workbookState";
import type { WorkbookState } from "./workbookState";
type FormulaBarProps = { type FormulaBarProps = {
cellAddress: string; cellAddress: string;

View File

@@ -3,8 +3,8 @@ import type { MenuItemProps } from "@mui/material";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { theme } from "../../theme"; import { theme } from "../../theme";
import ColorPicker from "../colorPicker"; import ColorPicker from "../ColorPicker/ColorPicker";
import { isInReferenceMode } from "../editor/util"; import { isInReferenceMode } from "../Editor/util";
import type { WorkbookState } from "../workbookState"; import type { WorkbookState } from "../workbookState";
import SheetDeleteDialog from "./SheetDeleteDialog"; import SheetDeleteDialog from "./SheetDeleteDialog";
import SheetRenameDialog from "./SheetRenameDialog"; import SheetRenameDialog from "./SheetRenameDialog";

View File

@@ -3,8 +3,8 @@ import { Menu, Plus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { theme } from "../../theme"; import { theme } from "../../theme";
import { StyledButton } from "../Toolbar/Toolbar";
import { NAVIGATION_HEIGHT } from "../constants"; import { NAVIGATION_HEIGHT } from "../constants";
import { StyledButton } from "../toolbar";
import type { WorkbookState } from "../workbookState"; import type { WorkbookState } from "../workbookState";
import SheetListMenu from "./SheetListMenu"; import SheetListMenu from "./SheetListMenu";
import SheetTab from "./SheetTab"; import SheetTab from "./SheetTab";

View File

@@ -18,10 +18,13 @@ import {
Grid2X2, Grid2X2,
Grid2x2Check, Grid2x2Check,
Grid2x2X, Grid2x2X,
ImageDown,
Italic, Italic,
Minus,
PaintBucket, PaintBucket,
PaintRoller, PaintRoller,
Percent, Percent,
Plus,
Redo2, Redo2,
RemoveFormatting, RemoveFormatting,
Strikethrough, Strikethrough,
@@ -29,6 +32,7 @@ import {
Type, Type,
Underline, Underline,
Undo2, Undo2,
WrapText,
} from "lucide-react"; } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -36,19 +40,19 @@ import {
ArrowMiddleFromLine, ArrowMiddleFromLine,
DecimalPlacesDecreaseIcon, DecimalPlacesDecreaseIcon,
DecimalPlacesIncreaseIcon, DecimalPlacesIncreaseIcon,
} from "../icons"; } from "../../icons";
import { theme } from "../theme"; import { theme } from "../../theme";
import NameManagerDialog from "./NameManagerDialog"; import BorderPicker from "../BorderPicker/BorderPicker";
import type { NameManagerProperties } from "./NameManagerDialog/NameManagerDialog"; import ColorPicker from "../ColorPicker/ColorPicker";
import BorderPicker from "./borderPicker"; import FormatMenu from "../FormatMenu/FormatMenu";
import ColorPicker from "./colorPicker";
import { TOOLBAR_HEIGHT } from "./constants";
import FormatMenu from "./formatMenu";
import { import {
NumberFormats, NumberFormats,
decreaseDecimalPlaces, decreaseDecimalPlaces,
increaseDecimalPlaces, increaseDecimalPlaces,
} from "./formatUtil"; } from "../FormatMenu/formatUtil";
import NameManagerDialog from "../NameManagerDialog";
import type { NameManagerProperties } from "../NameManagerDialog/NameManagerDialog";
import { TOOLBAR_HEIGHT } from "../constants";
type ToolbarProperties = { type ToolbarProperties = {
canUndo: boolean; canUndo: boolean;
@@ -61,20 +65,25 @@ type ToolbarProperties = {
onToggleStrike: (v: boolean) => void; onToggleStrike: (v: boolean) => void;
onToggleHorizontalAlign: (v: string) => void; onToggleHorizontalAlign: (v: string) => void;
onToggleVerticalAlign: (v: string) => void; onToggleVerticalAlign: (v: string) => void;
onToggleWrapText: (v: boolean) => void;
onCopyStyles: () => void; onCopyStyles: () => void;
onTextColorPicked: (hex: string) => void; onTextColorPicked: (hex: string) => void;
onFillColorPicked: (hex: string) => void; onFillColorPicked: (hex: string) => void;
onNumberFormatPicked: (numberFmt: string) => void; onNumberFormatPicked: (numberFmt: string) => void;
onBorderChanged: (border: BorderOptions) => void; onBorderChanged: (border: BorderOptions) => void;
onClearFormatting: () => void; onClearFormatting: () => void;
onIncreaseFontSize: (delta: number) => void;
onDownloadPNG: () => void;
fillColor: string; fillColor: string;
fontColor: string; fontColor: string;
fontSize: number;
bold: boolean; bold: boolean;
underline: boolean; underline: boolean;
italic: boolean; italic: boolean;
strike: boolean; strike: boolean;
horizontalAlign: HorizontalAlignment; horizontalAlign: HorizontalAlignment;
verticalAlign: VerticalAlignment; verticalAlign: VerticalAlignment;
wrapText: boolean;
canEdit: boolean; canEdit: boolean;
numFmt: string; numFmt: string;
showGridLines: boolean; showGridLines: boolean;
@@ -200,6 +209,30 @@ function Toolbar(properties: ToolbarProperties) {
</StyledButton> </StyledButton>
</FormatMenu> </FormatMenu>
<Divider /> <Divider />
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
onClick={() => {
properties.onIncreaseFontSize(-1);
}}
title={t("toolbar.decrease_font_size")}
>
<Minus />
</StyledButton>
<FontSizeBox>{properties.fontSize}</FontSizeBox>
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
onClick={() => {
properties.onIncreaseFontSize(1);
}}
title={t("toolbar.increase_font_size")}
>
<Plus />
</StyledButton>
<Divider />
<StyledButton <StyledButton
type="button" type="button"
$pressed={properties.bold} $pressed={properties.bold}
@@ -336,6 +369,17 @@ function Toolbar(properties: ToolbarProperties) {
> >
<ArrowDownToLine /> <ArrowDownToLine />
</StyledButton> </StyledButton>
<StyledButton
type="button"
$pressed={properties.wrapText === true}
onClick={() => {
properties.onToggleWrapText(!properties.wrapText);
}}
disabled={!canEdit}
title={t("toolbar.wrap_text")}
>
<WrapText />
</StyledButton>
<Divider /> <Divider />
<StyledButton <StyledButton
@@ -374,6 +418,17 @@ function Toolbar(properties: ToolbarProperties) {
> >
<RemoveFormatting /> <RemoveFormatting />
</StyledButton> </StyledButton>
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
onClick={() => {
properties.onDownloadPNG();
}}
title={t("toolbar.selected_png")}
>
<ImageDown />
</StyledButton>
<ColorPicker <ColorPicker
color={properties.fontColor} color={properties.fontColor}
@@ -496,4 +551,16 @@ const Divider = styled("div")({
margin: "0px 12px", margin: "0px 12px",
}); });
const FontSizeBox = styled("div")({
width: "24px",
height: "24px",
lineHeight: "24px",
textAlign: "center",
fontFamily: "Inter",
fontSize: "11px",
border: `1px solid ${theme.palette.grey["300"]}`,
borderRadius: "4px",
minWidth: "24px",
});
export default Toolbar; export default Toolbar;

View File

@@ -6,30 +6,35 @@ import type {
} from "@ironcalc/wasm"; } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import SheetTabBar from "./SheetTabBar/SheetTabBar"; import FormulaBar from "../FormulaBar/FormulaBar";
import SheetTabBar from "../SheetTabBar";
import Toolbar from "../Toolbar/Toolbar";
import Worksheet from "../Worksheet/Worksheet";
import { import {
COLUMN_WIDTH_SCALE, COLUMN_WIDTH_SCALE,
LAST_COLUMN, LAST_COLUMN,
ROW_HEIGH_SCALE, ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants"; } from "../WorksheetCanvas/constants";
import type WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import { devicePixelRatio } from "../WorksheetCanvas/worksheetCanvas";
import { import {
CLIPBOARD_ID_SESSION_STORAGE_KEY, CLIPBOARD_ID_SESSION_STORAGE_KEY,
getNewClipboardId, getNewClipboardId,
} from "./clipboard"; } from "../clipboard";
import FormulaBar from "./formulabar";
import Toolbar from "./toolbar";
import useKeyboardNavigation from "./useKeyboardNavigation";
import { import {
type NavigationKey, type NavigationKey,
getCellAddress, getCellAddress,
getFullRangeToString, getFullRangeToString,
} from "./util"; } from "../util";
import type { WorkbookState } from "./workbookState"; import type { WorkbookState } from "../workbookState";
import Worksheet from "./worksheet"; import useKeyboardNavigation from "./useKeyboardNavigation";
const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
const { model, workbookState } = props; const { model, workbookState } = props;
const rootRef = useRef<HTMLDivElement | null>(null); const rootRef = useRef<HTMLDivElement | null>(null);
const worksheetRef = useRef<{
getCanvas: () => WorksheetCanvas | null;
}>(null);
// Calling `setRedrawId((id) => id + 1);` forces a redraw // Calling `setRedrawId((id) => id + 1);` forces a redraw
// This is needed because `model` or `workbookState` can change without React being aware of it // This is needed because `model` or `workbookState` can change without React being aware of it
@@ -107,6 +112,10 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
updateRangeStyle("alignment.vertical", value); updateRangeStyle("alignment.vertical", value);
}; };
const onToggleWrapText = (value: boolean) => {
updateRangeStyle("alignment.wrap_text", `${value}`);
};
const onTextColorPicked = (hex: string) => { const onTextColorPicked = (hex: string) => {
updateRangeStyle("font.color", hex); updateRangeStyle("font.color", hex);
}; };
@@ -119,6 +128,10 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
updateRangeStyle("num_fmt", numberFmt); updateRangeStyle("num_fmt", numberFmt);
}; };
const onIncreaseFontSize = (delta: number) => {
updateRangeStyle("font.size_delta", `${delta}`);
};
const onCopyStyles = () => { const onCopyStyles = () => {
const { const {
sheet, sheet,
@@ -523,6 +536,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
onToggleStrike={onToggleStrike} onToggleStrike={onToggleStrike}
onToggleHorizontalAlign={onToggleHorizontalAlign} onToggleHorizontalAlign={onToggleHorizontalAlign}
onToggleVerticalAlign={onToggleVerticalAlign} onToggleVerticalAlign={onToggleVerticalAlign}
onToggleWrapText={onToggleWrapText}
onCopyStyles={onCopyStyles} onCopyStyles={onCopyStyles}
onTextColorPicked={onTextColorPicked} onTextColorPicked={onTextColorPicked}
onFillColorPicked={onFillColorPicked} onFillColorPicked={onFillColorPicked}
@@ -541,6 +555,62 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
); );
setRedrawId((id) => id + 1); 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();
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);
let [x, y] = worksheetCanvas.getCoordinatesByCell(
firstRow,
firstColumn,
);
const [x1, y1] = worksheetCanvas.getCoordinatesByCell(
lastRow + 1,
lastColumn + 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 => { onBorderChanged={(border: BorderOptions): void => {
const { const {
sheet, sheet,
@@ -563,6 +633,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}} }}
fillColor={style.fill.fg_color || "#FFFFFF"} fillColor={style.fill.fg_color || "#FFFFFF"}
fontColor={style.font.color} fontColor={style.font.color}
fontSize={style.font.sz}
bold={style.font.b} bold={style.font.b}
underline={style.font.u} underline={style.font.u}
italic={style.font.i} italic={style.font.i}
@@ -573,6 +644,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
verticalAlign={ verticalAlign={
style.alignment?.vertical ? style.alignment.vertical : "bottom" style.alignment?.vertical ? style.alignment.vertical : "bottom"
} }
wrapText={style.alignment?.wrap_text || false}
canEdit={true} canEdit={true}
numFmt={style.num_fmt} numFmt={style.num_fmt}
showGridLines={model.getShowGridLines(model.getSelectedSheet())} showGridLines={model.getShowGridLines(model.getSelectedSheet())}
@@ -633,6 +705,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
refresh={(): void => { refresh={(): void => {
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}} }}
ref={worksheetRef}
/> />
<SheetTabBar <SheetTabBar

View File

@@ -1,5 +1,5 @@
import { type KeyboardEvent, type RefObject, useCallback } from "react"; import { type KeyboardEvent, type RefObject, useCallback } from "react";
import { type NavigationKey, isEditingKey, isNavigationKey } from "./util"; import { type NavigationKey, isEditingKey, isNavigationKey } from "../util";
export enum Border { export enum Border {
Top = "top", Top = "top",

View File

@@ -8,7 +8,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { theme } from "../theme"; import { theme } from "../../theme";
const red_color = theme.palette.error.main; const red_color = theme.palette.error.main;

View File

@@ -0,0 +1,676 @@
import { type Model, columnNameFromNumber } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
import {
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import Editor from "../Editor/Editor";
import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
LAST_ROW,
ROW_HEIGH_SCALE,
outlineBackgroundColor,
outlineColor,
} from "../WorksheetCanvas/constants";
import WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import {
FORMULA_BAR_HEIGHT,
NAVIGATION_HEIGHT,
TOOLBAR_HEIGHT,
} from "../constants";
import type { Cell } from "../types";
import { AreaType, type WorkbookState } from "../workbookState";
import CellContextMenu from "./CellContextMenu";
import usePointer from "./usePointer";
function useWindowSize() {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener("resize", updateSize);
updateSize();
return () => window.removeEventListener("resize", updateSize);
}, []);
return size;
}
const Worksheet = forwardRef(
(
props: {
model: Model;
workbookState: WorkbookState;
refresh: () => void;
},
ref,
) => {
const canvasElement = useRef<HTMLCanvasElement>(null);
const worksheetElement = useRef<HTMLDivElement>(null);
const scrollElement = useRef<HTMLDivElement>(null);
const editorElement = useRef<HTMLDivElement>(null);
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);
const columnHeaders = useRef<HTMLDivElement>(null);
const worksheetCanvas = useRef<WorksheetCanvas | null>(null);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const ignoreScrollEventRef = useRef(false);
const { model, workbookState, refresh } = props;
const [clientWidth, clientHeight] = useWindowSize();
useImperativeHandle(ref, () => ({
getCanvas: () => worksheetCanvas.current,
}));
useEffect(() => {
const canvasRef = canvasElement.current;
const columnGuideRef = columnResizeGuide.current;
const rowGuideRef = rowResizeGuide.current;
const columnHeadersRef = columnHeaders.current;
const worksheetRef = worksheetElement.current;
const outline = cellOutline.current;
const handle = cellOutlineHandle.current;
const area = areaOutline.current;
const extendTo = extendToOutline.current;
const editor = editorElement.current;
if (
!canvasRef ||
!columnGuideRef ||
!rowGuideRef ||
!columnHeadersRef ||
!worksheetRef ||
!outline ||
!handle ||
!area ||
!extendTo ||
!scrollElement.current ||
!editor
)
return;
// FIXME: This two need to be computed.
model.setWindowWidth(clientWidth - 37);
model.setWindowHeight(clientHeight - 190);
const canvas = new WorksheetCanvas({
width: worksheetRef.clientWidth,
height: worksheetRef.clientHeight,
model,
workbookState,
elements: {
canvas: canvasRef,
columnGuide: columnGuideRef,
rowGuide: rowGuideRef,
columnHeaders: columnHeadersRef,
cellOutline: outline,
cellOutlineHandle: handle,
areaOutline: area,
extendToOutline: extendTo,
editor: editor,
},
onColumnWidthChanges(sheet, column, width) {
if (width < 0) {
return;
}
const { range } = model.getSelectedView();
let columnStart = column;
let columnEnd = column;
const fullColumn = range[0] === 1 && range[2] === LAST_ROW;
const fullRow = range[1] === 1 && range[3] === LAST_COLUMN;
if (
fullColumn &&
column >= range[1] &&
column <= range[3] &&
!fullRow
) {
columnStart = Math.min(range[1], column, range[3]);
columnEnd = Math.max(range[1], column, range[3]);
}
model.setColumnsWidth(sheet, columnStart, columnEnd, width);
worksheetCanvas.current?.renderSheet();
},
onRowHeightChanges(sheet, row, height) {
if (height < 0) {
return;
}
const { range } = model.getSelectedView();
let rowStart = row;
let rowEnd = row;
const fullColumn = range[0] === 1 && range[2] === LAST_ROW;
const fullRow = range[1] === 1 && range[3] === LAST_COLUMN;
if (fullRow && row >= range[0] && row <= range[2] && !fullColumn) {
rowStart = Math.min(range[0], row, range[2]);
rowEnd = Math.max(range[0], row, range[2]);
}
model.setRowsHeight(sheet, rowStart, rowEnd, height);
worksheetCanvas.current?.renderSheet();
},
refresh,
});
const scrollX = model.getScrollX();
const scrollY = model.getScrollY();
const [sheetWidth, sheetHeight] = [scrollX + 100_000, scrollY + 500_000];
if (spacerElement.current) {
spacerElement.current.style.height = `${sheetHeight}px`;
spacerElement.current.style.width = `${sheetWidth}px`;
}
const left = scrollElement.current.scrollLeft;
const top = scrollElement.current.scrollTop;
if (scrollX !== left) {
ignoreScrollEventRef.current = true;
scrollElement.current.scrollLeft = scrollX;
setTimeout(() => {
ignoreScrollEventRef.current = false;
}, 0);
}
if (scrollY !== top) {
ignoreScrollEventRef.current = true;
scrollElement.current.scrollTop = scrollY;
setTimeout(() => {
ignoreScrollEventRef.current = false;
}, 0);
}
canvas.renderSheet();
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 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,
});
const onScroll = (): void => {
if (!scrollElement.current || !worksheetCanvas.current) {
return;
}
if (ignoreScrollEventRef.current) {
// Programmatic scroll ignored
return;
}
const left = scrollElement.current.scrollLeft;
const top = scrollElement.current.scrollTop;
worksheetCanvas.current.setScrollPosition({ left, top });
worksheetCanvas.current.renderSheet();
};
return (
<Wrapper ref={scrollElement} onScroll={onScroll} className="scroll">
<Spacer ref={spacerElement} />
<SheetContainer
className="sheet-container"
ref={worksheetElement}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuOpen(true);
}}
onDoubleClick={(event) => {
// Starts editing cell
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,
focus: "cell",
referencedRange: null,
activeRanges: [],
mode: "accept",
editorWidth,
editorHeight,
});
event.stopPropagation();
// event.preventDefault();
props.refresh();
}}
>
<SheetCanvas ref={canvasElement} />
<CellOutline ref={cellOutline} />
<EditorWrapper ref={editorElement}>
<Editor
originalText={workbookState.getEditingText()}
onEditEnd={(): void => {
props.refresh();
}}
onTextUpdated={(): void => {
props.refresh();
}}
model={model}
workbookState={workbookState}
type={"cell"}
/>
</EditorWrapper>
<AreaOutline ref={areaOutline} />
<ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle
ref={cellOutlineHandle}
onPointerDown={onPointerHandleDown}
/>
<ColumnResizeGuide ref={columnResizeGuide} />
<RowResizeGuide ref={rowResizeGuide} />
<ColumnHeaders ref={columnHeaders} />
</SheetContainer>
<CellContextMenu
open={contextMenuOpen}
onClose={() => setContextMenuOpen(false)}
anchorEl={cellOutline.current}
onInsertRowAbove={(): void => {
const view = model.getSelectedView();
model.insertRow(view.sheet, view.row);
setContextMenuOpen(false);
}}
onInsertRowBelow={(): void => {
const view = model.getSelectedView();
model.insertRow(view.sheet, view.row + 1);
setContextMenuOpen(false);
}}
onInsertColumnLeft={(): void => {
const view = model.getSelectedView();
model.insertColumn(view.sheet, view.column);
setContextMenuOpen(false);
}}
onInsertColumnRight={(): void => {
const view = model.getSelectedView();
model.insertColumn(view.sheet, view.column + 1);
setContextMenuOpen(false);
}}
onFreezeColumns={(): void => {
const view = model.getSelectedView();
model.setFrozenColumnsCount(view.sheet, view.column);
setContextMenuOpen(false);
}}
onFreezeRows={(): void => {
const view = model.getSelectedView();
model.setFrozenRowsCount(view.sheet, view.row);
setContextMenuOpen(false);
}}
onUnfreezeColumns={(): void => {
const sheet = model.getSelectedSheet();
model.setFrozenColumnsCount(sheet, 0);
setContextMenuOpen(false);
}}
onUnfreezeRows={(): void => {
const sheet = model.getSelectedSheet();
model.setFrozenRowsCount(sheet, 0);
setContextMenuOpen(false);
}}
onDeleteRow={(): void => {
const view = model.getSelectedView();
model.deleteRow(view.sheet, view.row);
setContextMenuOpen(false);
}}
onDeleteColumn={(): void => {
const view = model.getSelectedView();
model.deleteColumn(view.sheet, view.column);
setContextMenuOpen(false);
}}
row={model.getSelectedView().row}
column={columnNameFromNumber(model.getSelectedView().column)}
/>
</Wrapper>
);
},
);
const Spacer = styled("div")`
position: absolute;
height: 5000px;
width: 5000px;
`;
const SheetContainer = styled("div")`
position: sticky;
top: 0px;
left: 0px;
height: 100%;
.column-resize-handle {
position: absolute;
top: 0px;
width: 3px;
opacity: 0;
background: ${outlineColor};
border-radius: 5px;
cursor: col-resize;
}
.column-resize-handle:hover {
opacity: 1;
}
.row-resize-handle {
position: absolute;
left: 0px;
height: 3px;
opacity: 0;
background: ${outlineColor};
border-radius: 5px;
cursor: row-resize;
}
.row-resize-handle:hover {
opacity: 1;
}
`;
const Wrapper = styled("div")({
position: "absolute",
overflow: "scroll",
top: TOOLBAR_HEIGHT + FORMULA_BAR_HEIGHT + 1,
left: 0,
right: 0,
bottom: NAVIGATION_HEIGHT + 1,
overscrollBehavior: "none",
});
const SheetCanvas = styled("canvas")`
position: relative;
top: 0px;
left: 0px;
right: 0px;
bottom: 40px;
`;
const ColumnResizeGuide = styled("div")`
position: absolute;
top: 0px;
display: none;
height: 100%;
width: 0px;
border-left: 1px dashed ${outlineColor};
`;
const ColumnHeaders = styled("div")`
position: absolute;
left: 0px;
top: 0px;
overflow: hidden;
display: flex;
& .column-header {
display: inline-block;
text-align: center;
overflow: hidden;
height: 100%;
user-select: none;
}
`;
const RowResizeGuide = styled("div")`
position: absolute;
display: none;
left: 0px;
height: 0px;
width: 100%;
border-top: 1px dashed ${outlineColor};
`;
const AreaOutline = styled("div")`
position: absolute;
border: 1px solid ${outlineColor};
border-radius: 3px;
background-color: ${outlineBackgroundColor};
`;
const CellOutline = styled("div")`
position: absolute;
border: 2px solid ${outlineColor};
border-radius: 3px;
word-break: break-word;
font-size: 13px;
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};
border-radius: 3px;
`;
const EditorWrapper = styled("div")`
position: absolute;
width: 100%;
padding: 0px;
border-width: 0px;
outline: none;
resize: none;
white-space: pre-wrap;
vertical-align: bottom;
overflow: hidden;
text-align: left;
span {
min-width: 1px;
}
font-family: monospace;
border: 2px solid ${outlineColor};
`;
export default Worksheet;

View File

@@ -1,14 +1,14 @@
import type { Model } from "@ironcalc/wasm"; import type { Model } from "@ironcalc/wasm";
import { type PointerEvent, type RefObject, useCallback, useRef } from "react"; import { type PointerEvent, type RefObject, useCallback, useRef } from "react";
import type WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas"; import { isInReferenceMode } from "../Editor/util";
import type WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import { import {
headerColumnWidth, headerColumnWidth,
headerRowHeight, headerRowHeight,
} from "./WorksheetCanvas/worksheetCanvas"; } from "../WorksheetCanvas/worksheetCanvas";
import { isInReferenceMode } from "./editor/util"; import type { Cell } from "../types";
import type { Cell } from "./types"; import { rangeToStr } from "../util";
import { rangeToStr } from "./util"; import type { WorkbookState } from "../workbookState";
import type { WorkbookState } from "./workbookState";
interface PointerSettings { interface PointerSettings {
canvasElement: RefObject<HTMLCanvasElement | null>; canvasElement: RefObject<HTMLCanvasElement | null>;

View File

@@ -1,6 +1,6 @@
import type { Model } from "@ironcalc/wasm"; import type { Model } from "@ironcalc/wasm";
import { columnNameFromNumber } from "@ironcalc/wasm"; import { columnNameFromNumber } from "@ironcalc/wasm";
import { getColor } from "../editor/util"; import { getColor } from "../Editor/util";
import type { Cell } from "../types"; import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState"; import type { WorkbookState } from "../workbookState";
import { import {
@@ -70,6 +70,52 @@ function hexToRGBA10Percent(colorHex: string): string {
return `rgba(${red}, ${green}, ${blue}, ${alpha})`; 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 (const word of words) {
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 { export default class WorksheetCanvas {
sheetWidth: number; sheetWidth: number;
@@ -353,7 +399,7 @@ export default class WorksheetCanvas {
? gridColor ? gridColor
: backgroundColor; : backgroundColor;
const fontSize = 13; const fontSize = style.font?.sz || 13;
let font = `${fontSize}px ${defaultCellFontFamily}`; let font = `${fontSize}px ${defaultCellFontFamily}`;
let textColor = defaultTextColor; let textColor = defaultTextColor;
if (style.font) { if (style.font) {
@@ -371,6 +417,7 @@ export default class WorksheetCanvas {
if (style.alignment?.vertical) { if (style.alignment?.vertical) {
verticalAlign = style.alignment.vertical; verticalAlign = style.alignment.vertical;
} }
const wrapText = style.alignment?.wrap_text || false;
const context = this.ctx; const context = this.ctx;
context.font = font; context.font = font;
@@ -496,9 +543,14 @@ export default class WorksheetCanvas {
context.rect(x, y, width, height); context.rect(x, y, width, height);
context.clip(); context.clip();
// Is there any better parameter? // Is there any better to determine the line height?
const lineHeight = 22; const lineHeight = fontSize * 1.5;
const lines = fullText.split("\n"); const lines = computeWrappedLines(
fullText,
wrapText,
context,
width - padding,
);
const lineCount = lines.length; const lineCount = lines.length;
lines.forEach((text, line) => { lines.forEach((text, line) => {
@@ -608,14 +660,16 @@ export default class WorksheetCanvas {
const sheet = this.model.getSelectedSheet(); const sheet = this.model.getSelectedSheet();
const rows = this.model.getRowsWithData(sheet, column); const rows = this.model.getRowsWithData(sheet, column);
let width = 0; let width = 0;
// This is a bit of a HACK. We should use the actual font size and weather is bold or not
const fontSize = 13;
this.ctx.font = `${fontSize}px ${defaultCellFontFamily}`;
for (const row of rows) { for (const row of rows) {
const fullText = this.model.getFormattedCellValue(sheet, row, column); const fullText = this.model.getFormattedCellValue(sheet, row, column);
if (fullText === "") { if (fullText === "") {
continue; continue;
} }
const style = this.model.getCellStyle(sheet, row, column);
const fontSize = style.font.sz;
let font = `${fontSize}px ${defaultCellFontFamily}`;
font = style.font.b ? `bold ${font}` : `400 ${font}`;
this.ctx.font = font;
const lines = fullText.split("\n"); const lines = fullText.split("\n");
for (const line of lines) { for (const line of lines) {
const textWidth = this.ctx.measureText(line).width; const textWidth = this.ctx.measureText(line).width;
@@ -675,18 +729,26 @@ export default class WorksheetCanvas {
const sheet = this.model.getSelectedSheet(); const sheet = this.model.getSelectedSheet();
const columns = this.model.getColumnsWithData(sheet, row); const columns = this.model.getColumnsWithData(sheet, row);
let height = 0; let height = 0;
const lineHeight = 22;
// This is a bit of a HACK. We should use the actual font size and weather is bold or not
const fontSize = 13;
this.ctx.font = `${fontSize}px ${defaultCellFontFamily}`;
for (const column of columns) { for (const column of columns) {
const fullText = this.model.getFormattedCellValue(sheet, row, column); const fullText = this.model.getFormattedCellValue(sheet, row, column);
if (fullText === "") { if (fullText === "") {
continue; continue;
} }
const lines = fullText.split("\n"); const width = this.getColumnWidth(sheet, column);
const style = this.model.getCellStyle(sheet, row, column);
const fontSize = style.font.sz;
const lineHeight = fontSize * 1.5;
let font = `${fontSize}px ${defaultCellFontFamily}`;
font = style.font.b ? `bold ${font}` : `400 ${font}`;
this.ctx.font = font;
const lines = computeWrappedLines(
fullText,
style.alignment?.wrap_text || false,
this.ctx,
width,
);
const lineCount = lines.length; const lineCount = lines.length;
// This si computed so that the y position of the text is independent of the vertical alignment // This is computed so that the y position of the text is independent of the vertical alignment
const textHeight = (lineCount - 1) * lineHeight + 8 + fontSize; const textHeight = (lineCount - 1) * lineHeight + 8 + fontSize;
height = Math.max(height, textHeight); height = Math.max(height, textHeight);
} }

View File

@@ -1,7 +1,10 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { type SelectedView, initSync } from "@ironcalc/wasm"; import { type SelectedView, initSync } from "@ironcalc/wasm";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { decreaseDecimalPlaces, increaseDecimalPlaces } from "../formatUtil"; import {
decreaseDecimalPlaces,
increaseDecimalPlaces,
} from "../FormatMenu/formatUtil";
import { getFullRangeToString, isNavigationKey } from "../util"; import { getFullRangeToString, isNavigationKey } from "../util";
test("checks arrow left is a navigation key", () => { test("checks arrow left is a navigation key", () => {

View File

@@ -1,659 +0,0 @@
import { type Model, columnNameFromNumber } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import CellContextMenu from "./CellContextMenu";
import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
LAST_ROW,
ROW_HEIGH_SCALE,
outlineBackgroundColor,
outlineColor,
} from "./WorksheetCanvas/constants";
import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
import {
FORMULA_BAR_HEIGHT,
NAVIGATION_HEIGHT,
TOOLBAR_HEIGHT,
} from "./constants";
import Editor from "./editor/editor";
import type { Cell } from "./types";
import usePointer from "./usePointer";
import { AreaType, type WorkbookState } from "./workbookState";
function useWindowSize() {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener("resize", updateSize);
updateSize();
return () => window.removeEventListener("resize", updateSize);
}, []);
return size;
}
function Worksheet(props: {
model: Model;
workbookState: WorkbookState;
refresh: () => void;
}) {
const canvasElement = useRef<HTMLCanvasElement>(null);
const worksheetElement = useRef<HTMLDivElement>(null);
const scrollElement = useRef<HTMLDivElement>(null);
const editorElement = useRef<HTMLDivElement>(null);
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);
const columnHeaders = useRef<HTMLDivElement>(null);
const worksheetCanvas = useRef<WorksheetCanvas | null>(null);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const ignoreScrollEventRef = useRef(false);
const { model, workbookState, refresh } = props;
const [clientWidth, clientHeight] = useWindowSize();
useEffect(() => {
const canvasRef = canvasElement.current;
const columnGuideRef = columnResizeGuide.current;
const rowGuideRef = rowResizeGuide.current;
const columnHeadersRef = columnHeaders.current;
const worksheetRef = worksheetElement.current;
const outline = cellOutline.current;
const handle = cellOutlineHandle.current;
const area = areaOutline.current;
const extendTo = extendToOutline.current;
const editor = editorElement.current;
if (
!canvasRef ||
!columnGuideRef ||
!rowGuideRef ||
!columnHeadersRef ||
!worksheetRef ||
!outline ||
!handle ||
!area ||
!extendTo ||
!scrollElement.current ||
!editor
)
return;
// FIXME: This two need to be computed.
model.setWindowWidth(clientWidth - 37);
model.setWindowHeight(clientHeight - 190);
const canvas = new WorksheetCanvas({
width: worksheetRef.clientWidth,
height: worksheetRef.clientHeight,
model,
workbookState,
elements: {
canvas: canvasRef,
columnGuide: columnGuideRef,
rowGuide: rowGuideRef,
columnHeaders: columnHeadersRef,
cellOutline: outline,
cellOutlineHandle: handle,
areaOutline: area,
extendToOutline: extendTo,
editor: editor,
},
onColumnWidthChanges(sheet, column, width) {
if (width < 0) {
return;
}
const { range } = model.getSelectedView();
let columnStart = column;
let columnEnd = column;
const fullColumn = range[0] === 1 && range[2] === LAST_ROW;
const fullRow = range[1] === 1 && range[3] === LAST_COLUMN;
if (
fullColumn &&
column >= range[1] &&
column <= range[3] &&
!fullRow
) {
columnStart = Math.min(range[1], column, range[3]);
columnEnd = Math.max(range[1], column, range[3]);
}
model.setColumnsWidth(sheet, columnStart, columnEnd, width);
worksheetCanvas.current?.renderSheet();
},
onRowHeightChanges(sheet, row, height) {
if (height < 0) {
return;
}
const { range } = model.getSelectedView();
let rowStart = row;
let rowEnd = row;
const fullColumn = range[0] === 1 && range[2] === LAST_ROW;
const fullRow = range[1] === 1 && range[3] === LAST_COLUMN;
if (fullRow && row >= range[0] && row <= range[2] && !fullColumn) {
rowStart = Math.min(range[0], row, range[2]);
rowEnd = Math.max(range[0], row, range[2]);
}
model.setRowsHeight(sheet, rowStart, rowEnd, height);
worksheetCanvas.current?.renderSheet();
},
refresh,
});
const scrollX = model.getScrollX();
const scrollY = model.getScrollY();
const [sheetWidth, sheetHeight] = [scrollX + 100_000, scrollY + 500_000];
if (spacerElement.current) {
spacerElement.current.style.height = `${sheetHeight}px`;
spacerElement.current.style.width = `${sheetWidth}px`;
}
const left = scrollElement.current.scrollLeft;
const top = scrollElement.current.scrollTop;
if (scrollX !== left) {
ignoreScrollEventRef.current = true;
scrollElement.current.scrollLeft = scrollX;
setTimeout(() => {
ignoreScrollEventRef.current = false;
}, 0);
}
if (scrollY !== top) {
ignoreScrollEventRef.current = true;
scrollElement.current.scrollTop = scrollY;
setTimeout(() => {
ignoreScrollEventRef.current = false;
}, 0);
}
canvas.renderSheet();
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 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,
});
const onScroll = (): void => {
if (!scrollElement.current || !worksheetCanvas.current) {
return;
}
if (ignoreScrollEventRef.current) {
// Programmatic scroll ignored
return;
}
const left = scrollElement.current.scrollLeft;
const top = scrollElement.current.scrollTop;
worksheetCanvas.current.setScrollPosition({ left, top });
worksheetCanvas.current.renderSheet();
};
return (
<Wrapper ref={scrollElement} onScroll={onScroll} className="scroll">
<Spacer ref={spacerElement} />
<SheetContainer
className="sheet-container"
ref={worksheetElement}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuOpen(true);
}}
onDoubleClick={(event) => {
// Starts editing cell
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,
focus: "cell",
referencedRange: null,
activeRanges: [],
mode: "accept",
editorWidth,
editorHeight,
});
event.stopPropagation();
// event.preventDefault();
props.refresh();
}}
>
<SheetCanvas ref={canvasElement} />
<CellOutline ref={cellOutline} />
<EditorWrapper ref={editorElement}>
<Editor
originalText={workbookState.getEditingText()}
onEditEnd={(): void => {
props.refresh();
}}
onTextUpdated={(): void => {
props.refresh();
}}
model={model}
workbookState={workbookState}
type={"cell"}
/>
</EditorWrapper>
<AreaOutline ref={areaOutline} />
<ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle
ref={cellOutlineHandle}
onPointerDown={onPointerHandleDown}
/>
<ColumnResizeGuide ref={columnResizeGuide} />
<RowResizeGuide ref={rowResizeGuide} />
<ColumnHeaders ref={columnHeaders} />
</SheetContainer>
<CellContextMenu
open={contextMenuOpen}
onClose={() => setContextMenuOpen(false)}
anchorEl={cellOutline.current}
onInsertRowAbove={(): void => {
const view = model.getSelectedView();
model.insertRow(view.sheet, view.row);
setContextMenuOpen(false);
}}
onInsertRowBelow={(): void => {
const view = model.getSelectedView();
model.insertRow(view.sheet, view.row + 1);
setContextMenuOpen(false);
}}
onInsertColumnLeft={(): void => {
const view = model.getSelectedView();
model.insertColumn(view.sheet, view.column);
setContextMenuOpen(false);
}}
onInsertColumnRight={(): void => {
const view = model.getSelectedView();
model.insertColumn(view.sheet, view.column + 1);
setContextMenuOpen(false);
}}
onFreezeColumns={(): void => {
const view = model.getSelectedView();
model.setFrozenColumnsCount(view.sheet, view.column);
setContextMenuOpen(false);
}}
onFreezeRows={(): void => {
const view = model.getSelectedView();
model.setFrozenRowsCount(view.sheet, view.row);
setContextMenuOpen(false);
}}
onUnfreezeColumns={(): void => {
const sheet = model.getSelectedSheet();
model.setFrozenColumnsCount(sheet, 0);
setContextMenuOpen(false);
}}
onUnfreezeRows={(): void => {
const sheet = model.getSelectedSheet();
model.setFrozenRowsCount(sheet, 0);
setContextMenuOpen(false);
}}
onDeleteRow={(): void => {
const view = model.getSelectedView();
model.deleteRow(view.sheet, view.row);
setContextMenuOpen(false);
}}
onDeleteColumn={(): void => {
const view = model.getSelectedView();
model.deleteColumn(view.sheet, view.column);
setContextMenuOpen(false);
}}
row={model.getSelectedView().row}
column={columnNameFromNumber(model.getSelectedView().column)}
/>
</Wrapper>
);
}
const Spacer = styled("div")`
position: absolute;
height: 5000px;
width: 5000px;
`;
const SheetContainer = styled("div")`
position: sticky;
top: 0px;
left: 0px;
height: 100%;
.column-resize-handle {
position: absolute;
top: 0px;
width: 3px;
opacity: 0;
background: ${outlineColor};
border-radius: 5px;
cursor: col-resize;
}
.column-resize-handle:hover {
opacity: 1;
}
.row-resize-handle {
position: absolute;
left: 0px;
height: 3px;
opacity: 0;
background: ${outlineColor};
border-radius: 5px;
cursor: row-resize;
}
.row-resize-handle:hover {
opacity: 1;
}
`;
const Wrapper = styled("div")({
position: "absolute",
overflow: "scroll",
top: TOOLBAR_HEIGHT + FORMULA_BAR_HEIGHT + 1,
left: 0,
right: 0,
bottom: NAVIGATION_HEIGHT + 1,
overscrollBehavior: "none",
});
const SheetCanvas = styled("canvas")`
position: relative;
top: 0px;
left: 0px;
right: 0px;
bottom: 40px;
`;
const ColumnResizeGuide = styled("div")`
position: absolute;
top: 0px;
display: none;
height: 100%;
width: 0px;
border-left: 1px dashed ${outlineColor};
`;
const ColumnHeaders = styled("div")`
position: absolute;
left: 0px;
top: 0px;
overflow: hidden;
display: flex;
& .column-header {
display: inline-block;
text-align: center;
overflow: hidden;
height: 100%;
user-select: none;
}
`;
const RowResizeGuide = styled("div")`
position: absolute;
display: none;
left: 0px;
height: 0px;
width: 100%;
border-top: 1px dashed ${outlineColor};
`;
const AreaOutline = styled("div")`
position: absolute;
border: 1px solid ${outlineColor};
border-radius: 3px;
background-color: ${outlineBackgroundColor};
`;
const CellOutline = styled("div")`
position: absolute;
border: 2px solid ${outlineColor};
border-radius: 3px;
word-break: break-word;
font-size: 13px;
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};
border-radius: 3px;
`;
const EditorWrapper = styled("div")`
position: absolute;
width: 100%;
padding: 0px;
border-width: 0px;
outline: none;
resize: none;
white-space: pre-wrap;
vertical-align: bottom;
overflow: hidden;
text-align: left;
span {
min-width: 1px;
}
font-family: monospace;
border: 2px solid ${outlineColor};
`;
export default Worksheet;

View File

@@ -16,6 +16,8 @@
"format_number": "Format number", "format_number": "Format number",
"font_color": "Font color", "font_color": "Font color",
"fill_color": "Fill color", "fill_color": "Fill color",
"increase_font_size": "Increase font size",
"decrease_font_size": "Decrease font size",
"decimal_places_increase": "Increase decimal places", "decimal_places_increase": "Increase decimal places",
"decimal_places_decrease": "Decrease decimal places", "decimal_places_decrease": "Decrease decimal places",
"show_hide_grid_lines": "Show/hide grid lines", "show_hide_grid_lines": "Show/hide grid lines",
@@ -23,6 +25,8 @@
"vertical_align_bottom": "Align bottom", "vertical_align_bottom": "Align bottom",
"vertical_align_middle": " Align middle", "vertical_align_middle": " Align middle",
"vertical_align_top": "Align top", "vertical_align_top": "Align top",
"selected_png": "Export Selected area as PNG",
"wrap_text": "Wrap text",
"format_menu": { "format_menu": {
"auto": "Auto", "auto": "Auto",
"number": "Number", "number": "Number",
@@ -115,5 +119,8 @@
"freeze": "Freeze", "freeze": "Freeze",
"insert_row": "Insert row", "insert_row": "Insert row",
"insert_column": "Insert column" "insert_column": "Insert column"
},
"color_picker": {
"apply": "Apply"
} }
} }

View File

@@ -13,6 +13,7 @@
"@ironcalc/workbook": "file:../../IronCalc/", "@ironcalc/workbook": "file:../../IronCalc/",
"@mui/material": "^6.4", "@mui/material": "^6.4",
"lucide-react": "^0.473.0", "lucide-react": "^0.473.0",
"qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
@@ -43,21 +44,21 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@chromatic-com/storybook": "^3.2.4", "@chromatic-com/storybook": "^3.2.4",
"@storybook/addon-essentials": "^8.5.3", "@storybook/addon-essentials": "^8.6.0",
"@storybook/addon-interactions": "^8.5.3", "@storybook/addon-interactions": "^8.6.0",
"@storybook/blocks": "^8.5.3", "@storybook/blocks": "^8.6.0",
"@storybook/react": "^8.5.3", "@storybook/react": "^8.6.0",
"@storybook/react-vite": "^8.5.3", "@storybook/react-vite": "^8.6.0",
"@storybook/test": "^8.5.3", "@storybook/test": "^8.6.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"storybook": "^8.5.3", "storybook": "^8.6.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5", "vite": "^6.2.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vitest": "^2.0.5" "vitest": "^3.0.7"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0", "@types/react": "^18.0.0 || ^19.0.0",
@@ -2487,6 +2488,14 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.0.0", "version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",

View File

@@ -16,6 +16,7 @@
"@ironcalc/workbook": "file:../../IronCalc/", "@ironcalc/workbook": "file:../../IronCalc/",
"@mui/material": "^6.4", "@mui/material": "^6.4",
"lucide-react": "^0.473.0", "lucide-react": "^0.473.0",
"qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },

View File

@@ -1,13 +1,12 @@
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import type { Model } from "@ironcalc/workbook"; import type { Model } from "@ironcalc/workbook";
import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook"; import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook";
import { CircleCheck } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
// import { IronCalcIcon, IronCalcLogo } from "./../icons";
import { FileMenu } from "./FileMenu"; import { FileMenu } from "./FileMenu";
import { ShareButton } from "./ShareButton"; import { ShareButton } from "./ShareButton";
import ShareWorkbookDialog from "./ShareWorkbookDialog";
import { WorkbookTitle } from "./WorkbookTitle"; import { WorkbookTitle } from "./WorkbookTitle";
import { downloadModel, shareModel } from "./rpc"; import { downloadModel } from "./rpc";
import { updateNameSelectedWorkbook } from "./storage"; import { updateNameSelectedWorkbook } from "./storage";
export function FileBar(properties: { export function FileBar(properties: {
@@ -18,7 +17,8 @@ export function FileBar(properties: {
onDelete: () => void; onDelete: () => void;
}) { }) {
const hiddenInputRef = useRef<HTMLInputElement>(null); const hiddenInputRef = useRef<HTMLInputElement>(null);
const [toast, setToast] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
return ( return (
<FileBarWrapper> <FileBarWrapper>
<StyledDesktopLogo /> <StyledDesktopLogo />
@@ -53,37 +53,17 @@ export function FileBar(properties: {
type="text" type="text"
style={{ position: "absolute", left: -9999, top: -9999 }} style={{ position: "absolute", left: -9999, top: -9999 }}
/> />
<div style={{ marginLeft: "auto" }}> <div style={{ marginLeft: "auto" }} />
{toast ? ( <DialogContainer>
<Toast> <ShareButton onClick={() => setIsDialogOpen(true)} />
<CircleCheck style={{ width: 12 }} /> {isDialogOpen && (
<span <ShareWorkbookDialog
style={{ marginLeft: 8, marginRight: 12, fontFamily: "Inter" }} onClose={() => setIsDialogOpen(false)}
> onModelUpload={properties.onModelUpload}
URL copied to clipboard model={properties.model}
</span> />
</Toast>
) : (
""
)} )}
</div> </DialogContainer>
<ShareButton
onClick={async () => {
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);
}}
/>
</FileBarWrapper> </FileBarWrapper>
); );
} }
@@ -117,14 +97,6 @@ const HelpButton = styled("div")`
} }
`; `;
const Toast = styled("div")`
font-weight: 400;
font-size: 12px;
color: #9e9e9e;
display: flex;
align-items: center;
`;
const Divider = styled("div")` const Divider = styled("div")`
margin: 0px 8px 0px 16px; margin: 0px 8px 0px 16px;
height: 12px; height: 12px;
@@ -141,3 +113,17 @@ const FileBarWrapper = styled("div")`
position: relative; position: relative;
justify-content: space-between; justify-content: space-between;
`; `;
const DialogContainer = styled("div")`
position: relative;
display: inline-block;
button {
margin-bottom: 8px;
}
.MuiDialog-root {
position: absolute;
top: 100%;
left: 0;
transform: translateY(8px);
}
`;

View File

@@ -0,0 +1,202 @@
import type { Model } from "@ironcalc/workbook";
import { Button, Dialog, TextField, styled } from "@mui/material";
import { Check, Copy, GlobeLock } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react";
import { shareModel } from "./rpc";
function ShareWorkbookDialog(properties: {
onClose: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
model?: Model;
}) {
const [url, setUrl] = useState<string>("");
const [copied, setCopied] = useState(false);
useEffect(() => {
const generateUrl = async () => {
if (properties.model) {
const bytes = properties.model.toBytes();
const fileName = properties.model.getName();
const hash = await shareModel(bytes, fileName);
setUrl(`${location.origin}/?model=${hash}`);
}
};
generateUrl();
}, [properties.model]);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (copied) {
timeoutId = setTimeout(() => {
setCopied(false);
}, 2000);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [copied]);
const handleClose = () => {
properties.onClose();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<DialogWrapper
open={true}
tabIndex={0}
onClose={handleClose}
onKeyDown={(event) => {
if (event.code === "Escape") {
handleClose();
}
}}
>
<DialogContent>
<QRCodeWrapper>
<QRCodeSVG value={url} size={80} />{" "}
</QRCodeWrapper>
<URLWrapper>
<StyledTextField
hiddenLabel
disabled
value={url}
variant="outlined"
fullWidth
margin="normal"
size="small"
/>
<StyledButton
variant="contained"
color="primary"
size="small"
onClick={handleCopy}
>
{copied ? <StyledCheck /> : <StyledCopy />}
{copied ? "Copied!" : "Copy URL"}
</StyledButton>
</URLWrapper>
</DialogContent>
<UploadFooter>
<GlobeLock />
Anyone with the link will be able to access a copy of this workbook
</UploadFooter>
</DialogWrapper>
);
}
const DialogWrapper = styled(Dialog)`
.MuiDialog-paper {
width: 440px;
position: absolute;
top: 44px;
right: 0px;
margin: 10px;
max-width: calc(100% - 20px);
}
.MuiBackdrop-root {
background-color: transparent;
}
`;
const DialogContent = styled("div")`
padding: 20px;
display: flex;
flex-direction: row;
gap: 12px;
height: 80px;
`;
const URLWrapper = styled("div")`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
justify-content: space-between;
`;
const StyledTextField = styled(TextField)`
margin: 0px;
.MuiInputBase-root {
max-height: 36px;
font-size: 14px;
padding-top: 0px;
}
.MuiOutlinedInput-input {
text-overflow: ellipsis;
padding: 8px;
}
`;
const StyledButton = styled(Button)`
display: flex;
flex-direction: row;
gap: 4px;
background-color: #eeeeee;
height: 36px;
color: #616161;
box-shadow: none;
font-size: 14px;
text-transform: capitalize;
gap: 10px;
&:hover {
background-color: #e0e0e0;
box-shadow: none;
}
&:active {
background-color: #d4d4d4;
box-shadow: none;
}
`;
const StyledCopy = styled(Copy)`
width: 16px;
`;
const StyledCheck = styled(Check)`
width: 16px;
`;
const QRCodeWrapper = styled("div")`
min-height: 80px;
min-width: 80px;
background-color: grey;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 600px) {
display: none;
}
`;
const UploadFooter = styled("div")`
height: 44px;
border-top: 1px solid #e0e0e0;
font-size: 12px;
font-weight: 400;
color: #757575;
display: flex;
align-items: center;
font-family: Inter;
gap: 8px;
padding: 0px 12px;
svg {
max-width: 16px;
}
`;
export default ShareWorkbookDialog;

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironcalc_server" name = "ironcalc_server"
version = "0.3.0" version = "0.5.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironcalc" name = "ironcalc"
version = "0.3.0" version = "0.5.0"
authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"]
edition = "2021" edition = "2021"
homepage = "https://www.ironcalc.com" homepage = "https://www.ironcalc.com"
@@ -20,7 +20,7 @@ thiserror = "1.0"
# Uses `../base` when used locally, and uses # Uses `../base` when used locally, and uses
# the inicated version from crates.io when published. # the inicated version from crates.io when published.
# https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations
ironcalc_base = { path = "../base", version = "0.3" } ironcalc_base = { path = "../base", version = "0.5" }
itertools = "0.12" itertools = "0.12"
chrono = "0.4" chrono = "0.4"
bitcode = "0.6.0" bitcode = "0.6.0"

View File

@@ -9,11 +9,9 @@
//! //!
//! ```toml //! ```toml
//! [dependencies] //! [dependencies]
//! ironcalc = { git = "https://github.com/ironcalc/IronCalc" } //! ironcalc = { git = "https://github.com/ironcalc/IronCalc", tag = "v0.5.0" }
//! ``` //! ```
//! //!
//! <small> until version 0.5.0 you should use the git dependencies as stated </small>
//!
//! A simple example with some numbers, a new sheet and a formula: //! A simple example with some numbers, a new sheet and a formula:
//! //!
//! //!