Compare commits

...

51 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
Nicolás Hatcher
1ff0c38aa5 FIX: Control+B,I,U work again
This clearly shows we need beter testing in the frontend
2025-02-23 11:27:59 +01:00
Nicolás Hatcher
e5a2db4d8c FIX: Adds localhost in the development server Caddyfile
Useful for MacOs
2025-02-22 18:57:13 +01:00
Nicolás Hatcher
fc7335707a UPDATE: Double click resizes columns/rows automatically 2025-02-19 18:26:49 +01:00
Nicolás Hatcher
4095b7db6e UPDATE[API rename]: set_column_with => set_columns_with
Similarly set_row_height => set_rows_height
2025-02-19 18:26:49 +01:00
Nicolás Hatcher
dd9ca4224d UPDATE: Select multiple columns/rows
Also fixed a bug where a second column would not pick up salyes correctly
2025-02-19 18:26:49 +01:00
Nicolás Hatcher
5aa7617e97 FIX: It it possible to have DF scoped to the first sheet 2025-02-19 13:40:32 +01:00
Nicolás Hatcher
a10d1f4615 FIX: Fix a bug were a new column style would introduce an invalid format 2025-02-15 15:25:36 +00:00
Nicolás Hatcher
1e8441a674 FIX: Displace column styles properly when inserting columns 2025-02-15 15:25:36 +00:00
Nicolás Hatcher
b2c5027f56 FIX: Shows borders correctly in case of frozen rows.
Also draws the lines at the correct position
2025-02-15 12:46:11 +00:00
Nicolás Hatcher
91984dc920 FIX: Forces calculation after insert/delete columns/rows 2025-02-15 10:16:05 +00:00
Nicolás Hatcher
74be62823d FIX: Minor fixes and refactor 2025-02-15 09:46:39 +00:00
Nicolás Hatcher
edd00096b6 FIX: Minor fixes in column/row styles
Most notably deleting the formatting does not change width/height
2025-02-15 09:46:39 +00:00
Nicolás Hatcher
d764752f16 FIX: diverse issues with set/delete column and row styles 2025-02-15 09:46:39 +00:00
Nicolás Hatcher
ce6c908dc7 FIX: Delete row/column formatting
Also clear formatting clears all formatting including row/column
2025-02-15 09:46:39 +00:00
Nicolás Hatcher
6ee450709a FIX: Numerous fixes
This also fix old issues:

* Top border is only the top border of the selected area
  (and not he top border of each cell in the area)
* Same for bottom, left and right borders

Factored out all the border related issues to a new file
2025-02-15 09:46:39 +00:00
Nicolás Hatcher
23ab5dfef2 UPDATE: Add rows/column style APIs 2025-02-15 09:46:39 +00:00
Daniel
7e54cb6aa2 style: added a divider 2025-02-11 15:34:13 +01:00
Daniel
857ebabf16 style: menu padding 2025-02-11 15:34:13 +01:00
Daniel
f0af3048b7 style: replace hex colors with theme colors 2025-02-11 15:34:13 +01:00
Nicolás Hatcher
99125f1fea UPDATE: Adds cell context menu 2025-02-07 19:15:55 +01:00
Nicolás Hatcher
f96481feb8 UPDATE: Update documentation 2025-02-06 20:48:38 +01:00
Nicolás Hatcher
dc8bb6da21 FIX: Undo/redo delete/add page
Now we can undo adding or deleting worksheets
2025-02-05 21:52:34 +01:00
Nicolás Hatcher
d866e283e9 UPDATE: Update to React 19.0.0
Diverse fixes
2025-02-04 22:04:26 +01:00
Nicolás Hatcher
8a54f45d75 UPDATE: Add clear formatting
Fixes #267
2025-02-04 19:02:05 +01:00
Nicolás Hatcher
42d557d485 UPDATE: Python docs 2025-02-01 17:18:02 +01:00
Nicolás Hatcher
293f7c6de6 FIX: Use @ironcalc/workbook from file and bimp dependencies 2025-01-31 18:44:25 +01:00
Daniel
38325b0bb9 UPDATE: Add an empty state message to the Name Manager 2025-01-31 00:07:50 +01:00
Daniel
282ed16f0d fix: Remove commented code 2025-01-30 23:41:27 +01:00
Daniel
fd744d28a3 fix: use theme colors on divider 2025-01-30 23:41:27 +01:00
Daniel
9a717daf04 update: makes AddressContainer's width flexible to allow more space on mobile 2025-01-30 23:41:27 +01:00
Daniel
84bf859c2c fix: remove min-width in formatPicker to avoid input overlay on mobile devices 2025-01-30 23:41:27 +01:00
Daniel
e57101f279 update: remove ironcalc link on mobile, padding adjustments 2025-01-30 23:41:27 +01:00
Nicolás Hatcher
264fcac63c UPDATE: Exposes the model in UserModel
Fixes #262
2025-01-30 07:50:14 +01:00
Nicolás Hatcher
7777f8e5d6 UPDATE: Adds fixes to python upload script 2025-01-29 23:50:33 +01:00
Daniel
6aa73171c7 FIX: Wrong Discord invite link 2025-01-29 18:44:33 +01:00
Daniel
8051913b2d update: remove the Name Manager from the 'Unsupported Features' page 2025-01-29 18:44:13 +01:00
Daniel
cfa38548d5 update: Name Manager documentation page 2025-01-29 18:44:13 +01:00
Nicolás Hatcher
9787721c5a UPDATE: Add python workflow to publish on testpypi 2025-01-29 18:41:13 +01:00
91 changed files with 6332 additions and 3517 deletions

143
.github/workflows/pypi.yml vendored Normal file
View File

@@ -0,0 +1,143 @@
name: Upload component to Python Package Index
on:
workflow_dispatch:
inputs:
release:
type: boolean
default: false
required: false
description: "Release? If false, publish to test.pypi.org, if true, publish to pypi.org"
permissions:
contents: read
jobs:
linux:
runs-on: ubuntu-latest
strategy:
matrix:
target: [x86_64, x86, aarch64, armv7, s390x, ppc64le]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
manylinux: auto
working-directory: bindings/python
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels
path: bindings/python/dist
windows:
runs-on: windows-latest
strategy:
matrix:
target: [x64, x86]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
architecture: ${{ matrix.target }}
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
working-directory: bindings/python
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels
path: bindings/python/dist
macos:
runs-on: macos-latest
strategy:
matrix:
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist --find-interpreter
sccache: 'true'
working-directory: bindings/python
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels
path: bindings/python/dist
sdist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
working-directory: bindings/python
- name: Upload sdist
uses: actions/upload-artifact@v4
with:
name: wheels
path: bindings/python/dist
publish-to-test-pypi:
if: ${{ github.event.inputs.release != 'true' }}
name: >-
Publish Python 🐍 distribution 📦 to Test PyPI
runs-on: ubuntu-latest
needs: [linux, windows, macos, sdist]
steps:
- uses: actions/download-artifact@v3
with:
name: wheels
path: bindings/python/
- name: Publish distribution 📦 to Test PyPI
uses: PyO3/maturin-action@v1
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TEST_API_TOKEN }}
MATURIN_REPOSITORY_URL: "https://test.pypi.org/legacy/"
with:
command: upload
args: --skip-existing *
working-directory: bindings/python
publish-pypi:
if: ${{ github.event.inputs.release == 'true' }}
name: >-
Publish Python 🐍 distribution 📦 to PyPI
runs-on: ubuntu-latest
needs: [linux, windows, macos, sdist]
steps:
- uses: actions/download-artifact@v3
with:
name: wheels
path: bindings/python/
- name: Publish distribution 📦 to PyPI
uses: PyO3/maturin-action@v1
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
MATURIN_REPOSITORY_URL: "https://upload.pypi.org/legacy/"
with:
command: upload
args: --skip-existing *
working-directory: bindings/python

View File

@@ -8,12 +8,20 @@
- New document server (Thanks Dani!)
- New function FORMULATEXT
- Name Manager ([#212](https://github.com/ironcalc/IronCalc/pull/212) [#220](https://github.com/ironcalc/IronCalc/pull/220))
- Add context menu. We can now insert rows and columns. Freeze and unfreeze rows and columns. Delete rows and columns [#271]
- Add nodejs bindings [#254]
- Add python bindings for all platforms
- Add is split into the product and widget
- Add Python documentation [#260]
### Fixed
- Fixed several issues with pasting content
- Fixed several issues with borders
- Fixed bug where columns and rows could be resized to negative width and height, respectively
- Undo/redo when add/delete sheet now works [#270]
- Numerous small fixes
- Multiple fixes to the documentation
## [0.2.0] - 2024-11-06 (The HN release)

10
Cargo.lock generated
View File

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

View File

@@ -77,7 +77,7 @@ And visit <http://0.0.0.0:8000/ironcalc/>
Add the dependency to `Cargo.toml`:
```toml
[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`:

View File

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

View File

@@ -136,6 +136,33 @@ impl Model {
}),
);
// In the list of columns:
// * Keep all the columns to the left
// * Displace all the columns to the right
let worksheet = &mut self.workbook.worksheet_mut(sheet)?;
let mut new_columns = Vec::new();
for col in worksheet.cols.iter_mut() {
// range under study
let min = col.min;
let max = col.max;
if column > max {
// If the range under study is to our left, this is a noop
} else if column <= min {
// If the range under study is to our right, we displace it
col.min = min + column_count;
col.max = max + column_count;
} else {
// If the range under study is in the middle we augment it
col.max = max + column_count;
}
new_columns.push(col.clone());
}
// TODO: If in a row the cell to the right and left have the same style we should copy it
worksheet.cols = new_columns;
Ok(())
}

View File

@@ -1872,12 +1872,29 @@ impl Model {
}
/// Returns the style for cell (`sheet`, `row`, `column`)
/// If the cell does not have a style defined we check the row, otherwise the column and finally a default
pub fn get_style_for_cell(&self, sheet: u32, row: i32, column: i32) -> Result<Style, String> {
let style_index = self.get_cell_style_index(sheet, row, column)?;
let style = self.workbook.styles.get_style(style_index)?;
Ok(style)
}
/// Returns the style defined in a cell if any.
pub fn get_cell_style_or_none(
&self,
sheet: u32,
row: i32,
column: i32,
) -> Result<Option<Style>, String> {
let style = self
.workbook
.worksheet(sheet)?
.cell(row, column)
.map(|c| self.workbook.styles.get_style(c.get_style()))
.transpose();
style
}
/// Returns an internal binary representation of the workbook
///
/// See also:
@@ -2161,6 +2178,73 @@ impl Model {
Err("Defined name not found".to_string())
}
}
/// Returns the style object of a column, if any
pub fn get_column_style(&self, sheet: u32, column: i32) -> Result<Option<Style>, String> {
if let Some(worksheet) = self.workbook.worksheets.get(sheet as usize) {
let cols = &worksheet.cols;
for col in cols {
if column >= col.min && column <= col.max {
if let Some(style_index) = col.style {
let style = self.workbook.styles.get_style(style_index)?;
return Ok(Some(style));
}
return Ok(None);
}
}
Ok(None)
} else {
Err("Invalid sheet".to_string())
}
}
/// Returns the style object of a row, if any
pub fn get_row_style(&self, sheet: u32, row: i32) -> Result<Option<Style>, String> {
if let Some(worksheet) = self.workbook.worksheets.get(sheet as usize) {
let rows = &worksheet.rows;
for r in rows {
if row == r.r {
let style = self.workbook.styles.get_style(r.s)?;
return Ok(Some(style));
}
}
Ok(None)
} else {
Err("Invalid sheet".to_string())
}
}
/// Sets a column with style
pub fn set_column_style(
&mut self,
sheet: u32,
column: i32,
style: &Style,
) -> Result<(), String> {
let style_index = self.workbook.styles.get_style_index_or_create(style);
self.workbook
.worksheet_mut(sheet)?
.set_column_style(column, style_index)
}
/// Sets a row with style
pub fn set_row_style(&mut self, sheet: u32, row: i32, style: &Style) -> Result<(), String> {
let style_index = self.workbook.styles.get_style_index_or_create(style);
self.workbook
.worksheet_mut(sheet)?
.set_row_style(row, style_index)
}
/// Deletes the style of a column if the is any
pub fn delete_column_style(&mut self, sheet: u32, column: i32) -> Result<(), String> {
self.workbook
.worksheet_mut(sheet)?
.delete_column_style(column)
}
/// Deletes the style of a row if there is any
pub fn delete_row_style(&mut self, sheet: u32, row: i32) -> Result<(), String> {
self.workbook.worksheet_mut(sheet)?.delete_row_style(row)
}
}
#[cfg(test)]

View File

@@ -301,7 +301,7 @@ impl Model {
};
if sheet_index >= sheet_count {
return Err("Sheet index too large".to_string());
}
};
self.workbook.worksheets.remove(sheet_index as usize);
self.reset_parsed_structures();
Ok(())

View File

@@ -4,8 +4,6 @@ use crate::{
types::{Border, CellStyles, CellXfs, Fill, Font, NumFmt, Style, Styles},
};
// TODO: Move Styles and all related types from crate::types here
// Not doing it right now to not have conflicts with exporter branch
impl Styles {
fn get_font_index(&self, font: &Font) -> Option<i32> {
for (font_index, item) in self.fonts.iter().enumerate() {

View File

@@ -37,6 +37,7 @@ mod test_model_cell_clear_all;
mod test_model_is_empty_cell;
mod test_move_formula;
mod test_quote_prefix;
mod test_row_column_styles;
mod test_set_user_input;
mod test_sheet_markup;
mod test_sheets;

View File

@@ -0,0 +1,32 @@
#![allow(clippy::unwrap_used)]
use crate::{constants::DEFAULT_COLUMN_WIDTH, test::util::new_empty_model};
#[test]
fn test_model_set_cells_with_values_styles() {
let mut model = new_empty_model();
let style_base = model.get_style_for_cell(0, 1, 1).unwrap();
let mut style = style_base.clone();
style.font.b = true;
model.set_column_style(0, 10, &style).unwrap();
assert!(model.get_style_for_cell(0, 21, 10).unwrap().font.b);
model.delete_column_style(0, 10).unwrap();
// There are no styles in the column
assert!(model.workbook.worksheets[0].cols.is_empty());
// lets change the column width and check it does not affect the style
model
.set_column_width(0, 10, DEFAULT_COLUMN_WIDTH * 2.0)
.unwrap();
model.set_column_style(0, 10, &style).unwrap();
model.delete_column_style(0, 10).unwrap();
// There are no styles in the column
assert!(model.workbook.worksheets[0].cols.len() == 1);
}

View File

@@ -3,7 +3,9 @@ mod test_autofill_columns;
mod test_autofill_rows;
mod test_border;
mod test_clear_cells;
mod test_column_style;
mod test_defined_names;
mod test_delete_row_column_formatting;
mod test_diff_queue;
mod test_evaluation;
mod test_general;
@@ -13,9 +15,11 @@ mod test_on_area_selection;
mod test_on_expand_selected_range;
mod test_on_paste_styles;
mod test_paste_csv;
mod test_recursive;
mod test_rename_sheet;
mod test_row_column;
mod test_sheet_state;
mod test_sheets_undo_redo;
mod test_styles;
mod test_to_from_bytes;
mod test_undo_redo;

View File

@@ -9,7 +9,7 @@ fn add_undo_redo() {
model.set_user_input(1, 1, 1, "=1 + 1").unwrap();
model.set_user_input(1, 1, 2, "=A1*3").unwrap();
model
.set_column_width(1, 5, 5.0 * DEFAULT_COLUMN_WIDTH)
.set_columns_width(1, 5, 5, 5.0 * DEFAULT_COLUMN_WIDTH)
.unwrap();
model.new_sheet().unwrap();
model.set_user_input(2, 1, 1, "=Sheet2!B1").unwrap();
@@ -25,9 +25,6 @@ fn add_undo_redo() {
assert_eq!(model.get_formatted_cell_value(2, 1, 1), Ok("6".to_string()));
model.delete_sheet(1).unwrap();
assert!(!model.can_undo());
assert!(!model.can_redo());
}
#[test]

View File

@@ -520,14 +520,19 @@ fn borders_top() {
.unwrap();
model.set_area_with_border(range, &border_area).unwrap();
check_borders(&model);
for row in 5..9 {
for row in 4..9 {
for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let bottom = if row == 8 {
let bottom = if row != 4 {
None
} else {
Some(border_item.clone())
};
let top = if row != 5 {
None
} else {
Some(border_item.clone())
@@ -537,7 +542,7 @@ fn borders_top() {
diagonal_down: false,
left: None,
right: None,
top: Some(border_item.clone()),
top,
bottom,
diagonal: None,
};
@@ -647,12 +652,12 @@ fn borders_right() {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let left = if column == 6 {
let left = if column != 9 {
None
} else {
Some(border_item.clone())
};
let right = if column == 9 {
let right = if column != 8 {
None
} else {
Some(border_item.clone())
@@ -705,7 +710,7 @@ fn borders_bottom() {
color: Some("#FF5566".to_string()),
};
// The top will also have a value for all but the first one
let top = if row == 5 {
let bottom = if row != 8 {
None
} else {
Some(border_item.clone())
@@ -715,8 +720,8 @@ fn borders_bottom() {
diagonal_down: false,
left: None,
right: None,
top,
bottom: Some(border_item.clone()),
top: None,
bottom,
diagonal: None,
};
assert_eq!(style.border, expected_border);
@@ -751,18 +756,13 @@ fn borders_left() {
model.set_area_with_border(range, &border_area).unwrap();
for row in 5..9 {
for column in 5..9 {
for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let left = if column == 5 {
None
} else {
Some(border_item.clone())
};
let right = if column == 8 {
let left = if column != 6 {
None
} else {
Some(border_item.clone())
@@ -771,13 +771,29 @@ fn borders_left() {
diagonal_up: false,
diagonal_down: false,
left,
right,
right: None,
top: None,
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
// Column 5 has a border to the right, of course:
let style = model.get_cell_style(0, row, 5).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: Some(border_item.clone()),
top: None,
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
}
@@ -1018,10 +1034,7 @@ fn border_top() {
style: BorderStyle::Thin,
color: Some("#F2F2F2".to_string()),
};
assert_eq!(
model._get_cell_actual_border("C4").bottom,
Some(border_item)
);
assert_eq!(model._get_cell_actual_border("C4").top, Some(border_item));
model.undo().unwrap();

View File

@@ -0,0 +1,504 @@
#![allow(clippy::unwrap_used)]
use crate::constants::{DEFAULT_COLUMN_WIDTH, DEFAULT_ROW_HEIGHT, LAST_COLUMN, LAST_ROW};
use crate::expressions::types::Area;
use crate::UserModel;
#[test]
fn column_width() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
let style = model.get_cell_style(0, 1, 1).unwrap();
assert!(!style.font.i);
assert!(!style.font.b);
assert!(!style.font.u);
assert!(!style.font.strike);
assert_eq!(style.font.color, Some("#000000".to_owned()));
// Set the whole column style and check it works
model.update_range_style(&range, "font.b", "true").unwrap();
let style = model.get_cell_style(0, 109, 7).unwrap();
assert!(style.font.b);
// undo and check it works
model.undo().unwrap();
let style = model.get_cell_style(0, 109, 7).unwrap();
assert!(!style.font.b);
// redo and check it works
model.redo().unwrap();
let style = model.get_cell_style(0, 109, 7).unwrap();
assert!(style.font.b);
// change the column width and check it does not affect the style
model
.set_columns_width(0, 7, 7, DEFAULT_COLUMN_WIDTH * 2.0)
.unwrap();
let style = model.get_cell_style(0, 109, 7).unwrap();
assert!(style.font.b);
}
#[test]
fn existing_style() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let cell_g123 = Area {
sheet: 0,
row: 123,
column: 7,
width: 1,
height: 1,
};
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
// Set G123 background to red
model
.update_range_style(&cell_g123, "fill.bg_color", "#333444")
.unwrap();
// Now set the style of the whole column
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
// Get the style of G123
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#555666".to_owned()));
model.undo().unwrap();
// Check the style of G123 is now what it was before
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_owned()));
model.redo().unwrap();
// Check G123 has the column style now
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#555666".to_owned()));
}
#[test]
fn row_column() {
// We set the row style, then a column style
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
let row_3_range = Area {
sheet: 0,
row: 3,
column: 1,
width: LAST_COLUMN,
height: 1,
};
// update the row style
model
.update_range_style(&row_3_range, "fill.bg_color", "#333444")
.unwrap();
// update the column style
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
// Check G3 has the column style
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#555666".to_owned()));
// undo twice. Color must be default
model.undo().unwrap();
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_owned()));
model.undo().unwrap();
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
}
#[test]
fn column_row() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let default_style = model.get_cell_style(0, 3, 7).unwrap();
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
let row_3_range = Area {
sheet: 0,
row: 3,
column: 1,
width: LAST_COLUMN,
height: 1,
};
// update the column style
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
// update the row style
model
.update_range_style(&row_3_range, "fill.bg_color", "#333444")
.unwrap();
// Check G3 has the row style
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_owned()));
model.undo().unwrap();
// Check G3 has the column style
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#555666".to_owned()));
model.undo().unwrap();
// Check G3 has the default_style
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, default_style.fill.bg_color);
}
#[test]
fn row_column_column() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let column_c_range = Area {
sheet: 0,
row: 1,
column: 3,
width: 1,
height: LAST_ROW,
};
let column_e_range = Area {
sheet: 0,
row: 1,
column: 5,
width: 1,
height: LAST_ROW,
};
let row_5_range = Area {
sheet: 0,
row: 5,
column: 1,
width: LAST_COLUMN,
height: 1,
};
// update the row style
model
.update_range_style(&row_5_range, "fill.bg_color", "#333444")
.unwrap();
// update the column style
model
.update_range_style(&column_c_range, "fill.bg_color", "#555666")
.unwrap();
model
.update_range_style(&column_e_range, "fill.bg_color", "#CCC111")
.unwrap();
model.undo().unwrap();
model.undo().unwrap();
model.undo().unwrap();
// Test E5 has the default style
let style = model.get_cell_style(0, 5, 5).unwrap();
assert_eq!(style.fill.bg_color, None);
}
#[test]
fn width_column_undo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model
.set_columns_width(0, 7, 7, DEFAULT_COLUMN_WIDTH * 2.0)
.unwrap();
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
model
.update_range_style(&column_g_range, "fill.bg_color", "#CCC111")
.unwrap();
model.undo().unwrap();
assert_eq!(
model.get_column_width(0, 7).unwrap(),
DEFAULT_COLUMN_WIDTH * 2.0
);
}
#[test]
fn height_row_undo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model
.set_rows_height(0, 10, 10, DEFAULT_ROW_HEIGHT * 2.0)
.unwrap();
let row_10_range = Area {
sheet: 0,
row: 10,
column: 1,
width: LAST_COLUMN,
height: 1,
};
model
.update_range_style(&row_10_range, "fill.bg_color", "#CCC111")
.unwrap();
assert_eq!(
model.get_row_height(0, 10).unwrap(),
2.0 * DEFAULT_ROW_HEIGHT
);
model.undo().unwrap();
assert_eq!(
model.get_row_height(0, 10).unwrap(),
2.0 * DEFAULT_ROW_HEIGHT
);
model.undo().unwrap();
assert_eq!(model.get_row_height(0, 10).unwrap(), DEFAULT_ROW_HEIGHT);
}
#[test]
fn cell_row_undo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let cell_g12 = Area {
sheet: 0,
row: 12,
column: 7,
width: 1,
height: 1,
};
let row_12_range = Area {
sheet: 0,
row: 12,
column: 1,
width: LAST_COLUMN,
height: 1,
};
// Set G12 background to red
model
.update_range_style(&cell_g12, "fill.bg_color", "#333444")
.unwrap();
model
.update_range_style(&row_12_range, "fill.bg_color", "#CCC111")
.unwrap();
let style = model.get_cell_style(0, 12, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#CCC111".to_string()));
model.undo().unwrap();
let style = model.get_cell_style(0, 12, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_string()));
}
#[test]
fn set_column_style_then_cell() {
// We check that if we set a cell style in a column that already has a style
// the styles compound
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let cell_g12 = Area {
sheet: 0,
row: 12,
column: 7,
width: 1,
height: 1,
};
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
// Set G12 background to red
model
.update_range_style(&column_g_range, "fill.bg_color", "#333444")
.unwrap();
model
.update_range_style(&cell_g12, "alignment.horizontal", "center")
.unwrap();
let style = model.get_cell_style(0, 12, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_string()));
model.undo().unwrap();
model.undo().unwrap();
let style = model.get_cell_style(0, 12, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
}
#[test]
fn set_row_style_then_cell() {
// We check that if we set a cell style in a column that already has a style
// the styles compound
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let cell_g12 = Area {
sheet: 0,
row: 12,
column: 7,
width: 1,
height: 1,
};
let row_12_range = Area {
sheet: 0,
row: 12,
column: 1,
width: LAST_COLUMN,
height: 1,
};
// Set G12 background to red
model
.update_range_style(&row_12_range, "fill.bg_color", "#333444")
.unwrap();
model
.update_range_style(&cell_g12, "alignment.horizontal", "center")
.unwrap();
let style = model.get_cell_style(0, 12, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_string()));
}
#[test]
fn column_style_then_row_alignment() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
let row_3_range = Area {
sheet: 0,
row: 3,
column: 1,
width: LAST_COLUMN,
height: 1,
};
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
model
.update_range_style(&row_3_range, "alignment.horizontal", "center")
.unwrap();
// check the row alignment does not affect the column style
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#555666".to_string()));
}
#[test]
fn column_style_then_width() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
model
.set_columns_width(0, 7, 7, DEFAULT_COLUMN_WIDTH * 2.0)
.unwrap();
// Check column width worked:
assert_eq!(
model.get_column_width(0, 7).unwrap(),
DEFAULT_COLUMN_WIDTH * 2.0
);
}
#[test]
fn test_row_column_column() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let column_c_range = Area {
sheet: 0,
row: 1,
column: 3,
width: 1,
height: LAST_ROW,
};
let column_e_range = Area {
sheet: 0,
row: 1,
column: 5,
width: 1,
height: LAST_ROW,
};
let row_5_range = Area {
sheet: 0,
row: 5,
column: 1,
width: LAST_COLUMN,
height: 1,
};
// update the row style
model
.update_range_style(&row_5_range, "fill.bg_color", "#333444")
.unwrap();
// update the column style
model
.update_range_style(&column_c_range, "fill.bg_color", "#555666")
.unwrap();
model
.update_range_style(&column_e_range, "fill.bg_color", "#CCC111")
.unwrap();
// test E5 has the column style
let style = model.get_cell_style(0, 5, 5).unwrap();
assert_eq!(style.fill.bg_color, Some("#CCC111".to_string()));
}

View File

@@ -396,3 +396,30 @@ fn undo_redo() {
Ok("Hola!".to_string())
);
}
#[test]
fn change_scope_to_first_sheet() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.new_sheet().unwrap();
model.set_user_input(0, 1, 1, "Hello").unwrap();
model
.set_user_input(1, 2, 1, r#"=CONCATENATE(MyName, " world!")"#)
.unwrap();
model
.new_defined_name("myName", None, "Sheet1!$A$1")
.unwrap();
assert_eq!(
model.get_formatted_cell_value(1, 2, 1),
Ok("Hello world!".to_string())
);
model
.update_defined_name("myName", None, "myName", Some(0), "Sheet1!$A$1")
.unwrap();
assert_eq!(
model.get_formatted_cell_value(1, 2, 1),
Ok("#NAME?".to_string())
);
}

View File

@@ -0,0 +1,243 @@
#![allow(clippy::unwrap_used)]
use crate::{
constants::{DEFAULT_COLUMN_WIDTH, DEFAULT_ROW_HEIGHT, LAST_COLUMN, LAST_ROW},
expressions::types::Area,
UserModel,
};
#[test]
fn delete_column_formatting() {
// We are going to delete formatting in column G (7)
// There are cells with their own styles
// There are rows with their own styles
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let cell_g123 = Area {
sheet: 0,
row: 123,
column: 7,
width: 1,
height: 1,
};
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
let row_3_range = Area {
sheet: 0,
row: 3,
column: 1,
width: LAST_COLUMN,
height: 1,
};
// Set the style of the whole column
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
// Set G123 background to red
model
.update_range_style(&cell_g123, "fill.bg_color", "#FF5533")
.unwrap();
// Set the style of the whole row
model
.update_range_style(&row_3_range, "fill.bg_color", "#333444")
.unwrap();
// Delete the column formatting
model.range_clear_formatting(&column_g_range).unwrap();
// Check the style of G123 is now what it was before
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
// Check the style of the whole row is still there
let style = model.get_cell_style(0, 3, 1).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_owned()));
// Check the style of the whole column is now gone
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
let style = model.get_cell_style(0, 40, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
model.undo().unwrap();
// Check the style of G123 is now what it was before
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#FF5533".to_owned()));
// Check G3 is the row style
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_owned()));
// Check G40 is the column style
let style = model.get_cell_style(0, 40, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#555666".to_owned()));
model.redo().unwrap();
// Check the style of G123 is now what it was before
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
// Check the style of the whole row is still there
let style = model.get_cell_style(0, 3, 1).unwrap();
assert_eq!(style.fill.bg_color, Some("#333444".to_owned()));
// Check the style of the whole column is now gone
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
let style = model.get_cell_style(0, 40, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
}
#[test]
fn column_width() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model
.set_columns_width(0, 7, 7, DEFAULT_COLUMN_WIDTH * 2.0)
.unwrap();
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
// Set the style of the whole column
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
// Delete the column formatting
model.range_clear_formatting(&column_g_range).unwrap();
// This does not change the column width
assert_eq!(
model.get_column_width(0, 7).unwrap(),
2.0 * DEFAULT_COLUMN_WIDTH
);
model.undo().unwrap();
assert_eq!(
model.get_column_width(0, 7).unwrap(),
2.0 * DEFAULT_COLUMN_WIDTH
);
model.redo().unwrap();
assert_eq!(
model.get_column_width(0, 7).unwrap(),
2.0 * DEFAULT_COLUMN_WIDTH
);
}
#[test]
fn column_row_style_undo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model
.set_columns_width(0, 7, 7, DEFAULT_COLUMN_WIDTH * 2.0)
.unwrap();
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
let row_123_range = Area {
sheet: 0,
row: 123,
column: 1,
width: LAST_COLUMN,
height: 1,
};
let delete_range = Area {
sheet: 0,
row: 120,
column: 5,
width: 20,
height: 20,
};
// Set the style of the whole column
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
model
.update_range_style(&row_123_range, "fill.bg_color", "#111222")
.unwrap();
model.range_clear_formatting(&delete_range).unwrap();
// check G123 is empty
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
// uno clear formatting
model.undo().unwrap();
// G123 has the row style
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#111222".to_owned()));
// undo twice
model.undo().unwrap();
model.undo().unwrap();
// check G123 is empty
let style = model.get_cell_style(0, 123, 7).unwrap();
assert_eq!(style.fill.bg_color, None);
}
#[test]
fn column_row_row_height_undo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
let row_3_range = Area {
sheet: 0,
row: 3,
column: 1,
width: LAST_COLUMN,
height: 1,
};
model
.update_range_style(&column_g_range, "fill.bg_color", "#555666")
.unwrap();
model
.set_rows_height(0, 3, 3, DEFAULT_ROW_HEIGHT * 2.0)
.unwrap();
model
.update_range_style(&row_3_range, "fill.bg_color", "#111222")
.unwrap();
model.undo().unwrap();
// check G3 has the column style
let style = model.get_cell_style(0, 3, 7).unwrap();
assert_eq!(style.fill.bg_color, Some("#555666".to_string()));
}

View File

@@ -10,7 +10,7 @@ use crate::{
fn send_queue() {
let mut model1 = UserModel::from_model(new_empty_model());
let width = model1.get_column_width(0, 3).unwrap() * 3.0;
model1.set_column_width(0, 3, width).unwrap();
model1.set_columns_width(0, 3, 3, width).unwrap();
model1.set_user_input(0, 1, 2, "Hello IronCalc!").unwrap();
let send_queue = model1.flush_send_queue();
@@ -34,7 +34,7 @@ fn apply_external_diffs_wrong_str() {
fn queue_undo_redo() {
let mut model1 = UserModel::from_model(new_empty_model());
let width = model1.get_column_width(0, 3).unwrap() * 3.0;
model1.set_column_width(0, 3, width).unwrap();
model1.set_columns_width(0, 3, 3, width).unwrap();
model1.set_user_input(0, 1, 2, "Hello IronCalc!").unwrap();
assert!(model1.undo().is_ok());
assert!(model1.redo().is_ok());
@@ -57,8 +57,8 @@ fn queue_undo_redo_multiple() {
// do a bunch of things
model1.set_frozen_columns_count(0, 5).unwrap();
model1.set_frozen_rows_count(0, 6).unwrap();
model1.set_column_width(0, 7, 300.0).unwrap();
model1.set_row_height(0, 23, 123.0).unwrap();
model1.set_columns_width(0, 7, 7, 300.0).unwrap();
model1.set_rows_height(0, 23, 23, 123.0).unwrap();
model1.set_user_input(0, 55, 55, "=42+8").unwrap();
for row in 1..5 {

View File

@@ -59,7 +59,7 @@ fn insert_remove_rows() {
// Insert some data in row 5 (and change the style)
assert!(model.set_user_input(0, 5, 1, "100$").is_ok());
// Change the height of the column
assert!(model.set_row_height(0, 5, 3.0 * height).is_ok());
assert!(model.set_rows_height(0, 5, 5, 3.0 * height).is_ok());
// remove the row
assert!(model.delete_row(0, 5).is_ok());
@@ -95,7 +95,7 @@ fn insert_remove_columns() {
// Insert some data in row 5 (and change the style) in E1
assert!(model.set_user_input(0, 1, 5, "100$").is_ok());
// Change the width of the column
assert!(model.set_column_width(0, 5, 3.0 * column_width).is_ok());
assert!(model.set_columns_width(0, 5, 5, 3.0 * column_width).is_ok());
assert_eq!(model.get_column_width(0, 5).unwrap(), 3.0 * column_width);
// remove the column

View File

@@ -0,0 +1,42 @@
#![allow(clippy::unwrap_used)]
use crate::{
constants::LAST_ROW, expressions::types::Area, test::util::new_empty_model, UserModel,
};
#[test]
fn two_columns() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
// Set style in column C (column 3)
let column_c_range = Area {
sheet: 0,
row: 1,
column: 3,
width: 1,
height: LAST_ROW,
};
model
.update_range_style(&column_c_range, "fill.bg_color", "#333444")
.unwrap();
model.set_user_input(0, 5, 3, "2").unwrap();
// Set Style in column G (column 7)
let column_g_range = Area {
sheet: 0,
row: 1,
column: 7,
width: 1,
height: LAST_ROW,
};
model
.update_range_style(&column_g_range, "fill.bg_color", "#333444")
.unwrap();
model.set_user_input(0, 5, 6, "42").unwrap();
// Set formula in G5: =F5*C5
model.set_user_input(0, 5, 7, "=F5*C5").unwrap();
assert_eq!(model.get_formatted_cell_value(0, 5, 7).unwrap(), "84");
}

View File

@@ -59,7 +59,7 @@ fn simple_delete_column() {
model.set_user_input(0, 1, 5, "3").unwrap();
model.set_user_input(0, 2, 5, "=E1*2").unwrap();
model
.set_column_width(0, 5, DEFAULT_COLUMN_WIDTH * 3.0)
.set_columns_width(0, 5, 5, DEFAULT_COLUMN_WIDTH * 3.0)
.unwrap();
model.delete_column(0, 5).unwrap();
@@ -116,7 +116,7 @@ fn simple_delete_row() {
model.set_user_input(0, 15, 6, "=D15*2").unwrap();
model
.set_row_height(0, 15, DEFAULT_ROW_HEIGHT * 3.0)
.set_rows_height(0, 15, 15, DEFAULT_ROW_HEIGHT * 3.0)
.unwrap();
model.delete_row(0, 15).unwrap();
@@ -170,5 +170,44 @@ fn row_heigh_increases_automatically() {
model
.set_user_input(0, 1, 1, "My home in Canada had horses\nAnd monkeys!")
.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]
fn insert_row_evaluates() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_user_input(0, 1, 2, "=A1*2").unwrap();
assert!(model.insert_row(0, 1).is_ok());
assert_eq!(model.get_formatted_cell_value(0, 2, 2).unwrap(), "84");
model.undo().unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2).unwrap(), "84");
model.redo().unwrap();
assert_eq!(model.get_formatted_cell_value(0, 2, 2).unwrap(), "84");
model.delete_row(0, 1).unwrap();
assert_eq!(model.get_formatted_cell_value(0, 1, 2).unwrap(), "84");
assert_eq!(model.get_cell_content(0, 1, 2).unwrap(), "=A1*2");
}
#[test]
fn insert_column_evaluates() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_user_input(0, 10, 1, "=A1*2").unwrap();
assert!(model.insert_column(0, 1).is_ok());
assert_eq!(model.get_formatted_cell_value(0, 10, 2).unwrap(), "84");
model.undo().unwrap();
assert_eq!(model.get_formatted_cell_value(0, 10, 1).unwrap(), "84");
model.redo().unwrap();
assert_eq!(model.get_formatted_cell_value(0, 10, 2).unwrap(), "84");
model.delete_column(0, 1).unwrap();
assert_eq!(model.get_formatted_cell_value(0, 10, 1).unwrap(), "84");
assert_eq!(model.get_cell_content(0, 10, 1).unwrap(), "=A1*2");
}

View File

@@ -0,0 +1,52 @@
#![allow(clippy::unwrap_used)]
use crate::test::util::new_empty_model;
use crate::UserModel;
#[test]
fn basic_undo_redo() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
assert_eq!(model.get_selected_sheet(), 0);
model.new_sheet().unwrap();
assert_eq!(model.get_selected_sheet(), 1);
model.undo().unwrap();
assert_eq!(model.get_selected_sheet(), 0);
{
let props = model.get_worksheets_properties();
assert_eq!(props.len(), 1);
let view = model.get_selected_view();
assert_eq!(view.sheet, 0);
}
model.redo().unwrap();
assert_eq!(model.get_selected_sheet(), 1);
{
let props = model.get_worksheets_properties();
assert_eq!(props.len(), 2);
let view = model.get_selected_view();
assert_eq!(view.sheet, 1);
}
}
#[test]
fn delete_undo() {
let model = new_empty_model();
let mut model = UserModel::from_model(model);
assert_eq!(model.get_selected_sheet(), 0);
model.new_sheet().unwrap();
assert_eq!(model.get_selected_sheet(), 1);
model.set_user_input(1, 1, 1, "42").unwrap();
model.set_user_input(1, 1, 2, "=A1*2").unwrap();
model.delete_sheet(1).unwrap();
assert_eq!(model.get_selected_sheet(), 0);
model.undo().unwrap();
assert_eq!(model.get_selected_sheet(), 1);
model.redo().unwrap();
assert_eq!(model.get_selected_sheet(), 0);
}

View File

@@ -436,3 +436,47 @@ fn false_removes_value() {
let style = model.get_cell_style(0, 1, 1).unwrap();
assert!(!style.font.b);
}
#[test]
fn cell_clear_formatting() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
let range = Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
};
// bold
model.update_range_style(&range, "font.b", "true").unwrap();
model
.update_range_style(&range, "alignment.horizontal", "centerContinuous")
.unwrap();
let style = model.get_cell_style(0, 1, 1).unwrap();
assert!(style.font.b);
assert_eq!(
style.alignment.unwrap().horizontal,
HorizontalAlignment::CenterContinuous
);
model.range_clear_all(&range).unwrap();
let style = model.get_cell_style(0, 1, 1).unwrap();
assert!(!style.font.b);
assert_eq!(style.alignment, None);
model.undo().unwrap();
let style = model.get_cell_style(0, 1, 1).unwrap();
assert!(style.font.b);
assert_eq!(
style.alignment.unwrap().horizontal,
HorizontalAlignment::CenterContinuous
);
model.redo().unwrap();
let style = model.get_cell_style(0, 1, 1).unwrap();
assert!(!style.font.b);
assert_eq!(style.alignment, None);
}

View File

@@ -6,7 +6,7 @@ use crate::{test::util::new_empty_model, UserModel};
fn basic() {
let mut model1 = UserModel::from_model(new_empty_model());
let width = model1.get_column_width(0, 3).unwrap() * 3.0;
model1.set_column_width(0, 3, width).unwrap();
model1.set_columns_width(0, 3, 3, width).unwrap();
model1.set_user_input(0, 1, 2, "Hello IronCalc!").unwrap();
let model_bytes = model1.to_bytes();

View File

@@ -323,6 +323,19 @@ pub struct Style {
pub quote_prefix: bool,
}
impl Default for Style {
fn default() -> Self {
Style {
alignment: None,
num_fmt: "general".to_string(),
fill: Fill::default(),
font: Font::default(),
border: Border::default(),
quote_prefix: false,
}
}
}
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct NumFmt {
pub num_fmt_id: i32,
@@ -394,7 +407,7 @@ impl Default for Font {
u: false,
b: false,
i: false,
sz: 11,
sz: 13,
color: Some("#000000".to_string()),
name: "Calibri".to_string(),
family: 2,

View File

@@ -50,8 +50,9 @@ impl Units {
fn get_units_from_format_string(num_fmt: &str) -> Option<Units> {
let mut parser = Parser::new(num_fmt);
parser.parse();
let parts = parser.parts.first()?;
// We only care about the first part (positive number)
match &parser.parts[0] {
match parts {
ParsePart::Number(part) => {
if part.percent > 0 {
Some(Units::Percentage {

View File

@@ -0,0 +1,507 @@
use crate::{
constants::{LAST_COLUMN, LAST_ROW},
expressions::types::Area,
};
use super::{
border_utils::is_max_border, common::BorderType, history::Diff, BorderArea, UserModel,
};
impl UserModel {
fn update_single_cell_border(
&mut self,
border_area: &BorderArea,
cell: (u32, i32, i32),
range: (i32, i32, i32, i32),
diff_list: &mut Vec<Diff>,
) -> Result<(), String> {
let (sheet, row, column) = cell;
let (first_row, first_column, last_row, last_column) = range;
let old_value = self.model.get_cell_style_or_none(sheet, row, column)?;
let mut new_value = match &old_value {
Some(value) => value.clone(),
None => Default::default(),
};
match border_area.r#type {
BorderType::All => {
new_value.border.top = Some(border_area.item.clone());
new_value.border.right = Some(border_area.item.clone());
new_value.border.bottom = Some(border_area.item.clone());
new_value.border.left = Some(border_area.item.clone());
}
BorderType::Inner => {
if row != first_row {
new_value.border.top = Some(border_area.item.clone());
}
if row != last_row {
new_value.border.bottom = Some(border_area.item.clone());
}
if column != first_column {
new_value.border.left = Some(border_area.item.clone());
}
if column != last_column {
new_value.border.right = Some(border_area.item.clone());
}
}
BorderType::Outer => {
if row == first_row {
new_value.border.top = Some(border_area.item.clone());
}
if row == last_row {
new_value.border.bottom = Some(border_area.item.clone());
}
if column == first_column {
new_value.border.left = Some(border_area.item.clone());
}
if column == last_column {
new_value.border.right = Some(border_area.item.clone());
}
}
BorderType::Top => {
if row == first_row {
new_value.border.top = Some(border_area.item.clone());
}
}
BorderType::Right => {
if column == last_column {
new_value.border.right = Some(border_area.item.clone());
}
}
BorderType::Bottom => {
if row == last_row {
new_value.border.bottom = Some(border_area.item.clone());
}
}
BorderType::Left => {
if column == first_column {
new_value.border.left = Some(border_area.item.clone());
}
}
BorderType::CenterH => {
if row != first_row {
new_value.border.top = Some(border_area.item.clone());
}
if row != last_row {
new_value.border.bottom = Some(border_area.item.clone());
}
}
BorderType::CenterV => {
if column != first_column {
new_value.border.left = Some(border_area.item.clone());
}
if column != last_column {
new_value.border.right = Some(border_area.item.clone());
}
}
BorderType::None => {
new_value.border.top = None;
new_value.border.right = None;
new_value.border.bottom = None;
new_value.border.left = None;
}
}
self.model.set_cell_style(sheet, row, column, &new_value)?;
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(old_value),
new_value: Box::new(new_value),
});
Ok(())
}
fn set_rows_with_border(
&mut self,
sheet: u32,
first_row: i32,
last_row: i32,
border_area: &BorderArea,
) -> Result<(), String> {
let mut diff_list = Vec::new();
for row in first_row..=last_row {
let old_value = self.model.get_row_style(sheet, row)?;
let mut new_value = match &old_value {
Some(value) => value.clone(),
None => Default::default(),
};
match border_area.r#type {
BorderType::All => {
new_value.border.top = Some(border_area.item.clone());
new_value.border.right = Some(border_area.item.clone());
new_value.border.bottom = Some(border_area.item.clone());
new_value.border.left = Some(border_area.item.clone());
}
BorderType::Inner => {
if row != first_row {
new_value.border.top = Some(border_area.item.clone());
}
if row != last_row {
new_value.border.bottom = Some(border_area.item.clone());
}
}
BorderType::Outer => {
if row == first_row {
new_value.border.top = Some(border_area.item.clone());
}
if row == last_row {
new_value.border.bottom = Some(border_area.item.clone());
}
}
BorderType::Top => {
if row == first_row {
new_value.border.top = Some(border_area.item.clone());
}
}
BorderType::Right => {
// noop
}
BorderType::Bottom => {
if row == last_row {
new_value.border.bottom = Some(border_area.item.clone());
}
}
BorderType::Left => {
// noop
}
BorderType::CenterH => {
if row != first_row {
new_value.border.top = Some(border_area.item.clone());
}
if row != last_row {
new_value.border.bottom = Some(border_area.item.clone());
}
}
BorderType::CenterV => {
new_value.border.left = Some(border_area.item.clone());
new_value.border.right = Some(border_area.item.clone());
}
BorderType::None => {
new_value.border.top = None;
new_value.border.right = None;
new_value.border.bottom = None;
new_value.border.left = None;
}
}
// We need to go throw each non-empty cell in the row
let columns: Vec<i32> = self
.model
.workbook
.worksheet(sheet)?
.sheet_data
.get(&row)
.map(|row_data| row_data.keys().copied().collect())
.unwrap_or_default();
for column in columns {
self.update_single_cell_border(
border_area,
(sheet, row, column),
(first_row, 1, last_row, LAST_COLUMN),
&mut diff_list,
)?;
}
self.model.set_row_style(sheet, row, &new_value)?;
diff_list.push(Diff::SetRowStyle {
sheet,
row,
old_value: Box::new(old_value),
new_value: Box::new(new_value),
});
}
// TODO: We need to check the rows above and below. also any non empty cell in the rows above and below.
self.push_diff_list(diff_list);
Ok(())
}
fn set_columns_with_border(
&mut self,
sheet: u32,
first_column: i32,
last_column: i32,
border_area: &BorderArea,
) -> Result<(), String> {
let mut diff_list = Vec::new();
// We need all the rows in the column to update the style
// NB: This is too much, this is all the rows that have values
let data_rows: Vec<i32> = self
.model
.workbook
.worksheet(sheet)?
.sheet_data
.keys()
.copied()
.collect();
let styled_rows = &self.model.workbook.worksheet(sheet)?.rows.clone();
for column in first_column..=last_column {
let old_value = self.model.get_column_style(sheet, column)?;
let mut new_value = match &old_value {
Some(value) => value.clone(),
None => Default::default(),
};
match border_area.r#type {
BorderType::All => {
new_value.border.top = Some(border_area.item.clone());
new_value.border.right = Some(border_area.item.clone());
new_value.border.bottom = Some(border_area.item.clone());
new_value.border.left = Some(border_area.item.clone());
}
BorderType::Inner => {
if column != first_column {
new_value.border.left = Some(border_area.item.clone());
}
if column != last_column {
new_value.border.right = Some(border_area.item.clone());
}
}
BorderType::Outer => {
if column == first_column {
new_value.border.left = Some(border_area.item.clone());
}
if column == last_column {
new_value.border.right = Some(border_area.item.clone());
}
}
BorderType::Top => {
// noop
}
BorderType::Right => {
if column == last_column {
new_value.border.right = Some(border_area.item.clone());
}
}
BorderType::Bottom => {
// noop
}
BorderType::Left => {
if column == first_column {
new_value.border.left = Some(border_area.item.clone());
}
}
BorderType::CenterH => {
new_value.border.top = Some(border_area.item.clone());
new_value.border.bottom = Some(border_area.item.clone());
}
BorderType::CenterV => {
if column != first_column {
new_value.border.left = Some(border_area.item.clone());
}
if column != last_column {
new_value.border.right = Some(border_area.item.clone());
}
}
BorderType::None => {
new_value.border.top = None;
new_value.border.right = None;
new_value.border.bottom = None;
new_value.border.left = None;
}
}
// We need to go through each non empty cell in the column
for &row in &data_rows {
if let Some(data_row) = self.model.workbook.worksheet(sheet)?.sheet_data.get(&row) {
if data_row.get(&column).is_some() {
self.update_single_cell_border(
border_area,
(sheet, row, column),
(1, first_column, LAST_ROW, last_column),
&mut diff_list,
)?;
}
}
}
// We also need to overwrite those that have a row style
for row_s in styled_rows.iter() {
let row = row_s.r;
self.update_single_cell_border(
border_area,
(sheet, row, column),
(1, first_column, LAST_ROW, last_column),
&mut diff_list,
)?;
}
self.model.set_column_style(sheet, column, &new_value)?;
diff_list.push(Diff::SetColumnStyle {
sheet,
column,
old_value: Box::new(old_value),
new_value: Box::new(new_value),
});
}
// We need to check the borders of the column to the left and the column to the right
// We also need to check every non-empty cell in the columns to the left and right
self.push_diff_list(diff_list);
Ok(())
}
/// Sets the border in an area of cells.
/// When setting the border we need to check if the adjacent cells have a "heavier" border
/// If that is the case we need to change it
pub fn set_area_with_border(
&mut self,
range: &Area,
border_area: &BorderArea,
) -> Result<(), String> {
let sheet = range.sheet;
let first_row = range.row;
let first_column = range.column;
let last_row = first_row + range.height - 1;
let last_column = first_column + range.width - 1;
if first_row == 1 && last_row == LAST_ROW {
// full columns
self.set_columns_with_border(sheet, first_column, last_column, border_area)?;
return Ok(());
}
if first_column == 1 && last_column == LAST_COLUMN {
// full rows
self.set_rows_with_border(sheet, first_row, last_row, border_area)?;
return Ok(());
}
let mut diff_list = Vec::new();
for row in first_row..=last_row {
for column in first_column..=last_column {
self.update_single_cell_border(
border_area,
(sheet, row, column),
(first_row, first_column, last_row, last_column),
&mut diff_list,
)?;
}
}
// bottom of the cells above the first
if first_row > 1
&& [
BorderType::All,
BorderType::None,
BorderType::Outer,
BorderType::Top,
]
.contains(&border_area.r#type)
{
let row = first_row - 1;
for column in first_column..=last_column {
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
if is_max_border(Some(&border_area.item), old_value.border.bottom.as_ref()) {
let mut style = old_value.clone();
if border_area.r#type == BorderType::None {
style.border.bottom = None;
} else {
style.border.bottom = Some(border_area.item.clone());
}
self.model.set_cell_style(sheet, row, column, &style)?;
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(Some(old_value)),
new_value: Box::new(style),
});
}
}
}
// Cells to the right
if last_column < LAST_COLUMN
&& [
BorderType::All,
BorderType::None,
BorderType::Outer,
BorderType::Right,
]
.contains(&border_area.r#type)
{
let column = last_column + 1;
for row in first_row..=last_row {
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
// If the border in the adjacent cell is "heavier" we change it
if is_max_border(Some(&border_area.item), old_value.border.left.as_ref()) {
let mut style = old_value.clone();
if border_area.r#type == BorderType::None {
style.border.left = None;
} else {
style.border.left = Some(border_area.item.clone());
}
self.model.set_cell_style(sheet, row, column, &style)?;
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(Some(old_value)),
new_value: Box::new(style),
});
}
}
}
// Cells bellow
if last_row < LAST_ROW
&& [
BorderType::All,
BorderType::None,
BorderType::Outer,
BorderType::Bottom,
]
.contains(&border_area.r#type)
{
let row = last_row + 1;
for column in first_column..=last_column {
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
if is_max_border(Some(&border_area.item), old_value.border.top.as_ref()) {
let mut style = old_value.clone();
if border_area.r#type == BorderType::None {
style.border.top = None;
} else {
style.border.top = Some(border_area.item.clone());
}
self.model.set_cell_style(sheet, row, column, &style)?;
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(Some(old_value)),
new_value: Box::new(style),
});
}
}
}
// Cells to the left
if first_column > 1
&& [
BorderType::All,
BorderType::None,
BorderType::Outer,
BorderType::Left,
]
.contains(&border_area.r#type)
{
let column = first_column - 1;
for row in first_row..=last_row {
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
if is_max_border(Some(&border_area.item), old_value.border.right.as_ref()) {
let mut style = old_value.clone();
if border_area.r#type == BorderType::None {
style.border.right = None;
} else {
style.border.right = Some(border_area.item.clone());
}
self.model.set_cell_style(sheet, row, column, &style)?;
diff_list.push(Diff::SetCellStyle {
sheet,
row,
column,
old_value: Box::new(Some(old_value)),
new_value: Box::new(style),
});
}
}
}
self.push_diff_list(diff_list);
Ok(())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ use std::collections::HashMap;
use bitcode::{Decode, Encode};
use crate::types::{Cell, Col, Row, SheetState, Style};
use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet};
#[derive(Clone, Encode, Decode)]
pub(crate) struct RowData {
@@ -39,11 +39,17 @@ pub(crate) enum Diff {
old_value: Box<Option<Cell>>,
old_style: Box<Style>,
},
CellClearFormatting {
sheet: u32,
row: i32,
column: i32,
old_style: Box<Option<Style>>,
},
SetCellStyle {
sheet: u32,
row: i32,
column: i32,
old_value: Box<Style>,
old_value: Box<Option<Style>>,
new_value: Box<Style>,
},
// Column and Row diffs
@@ -59,6 +65,28 @@ pub(crate) enum Diff {
new_value: f64,
old_value: f64,
},
SetColumnStyle {
sheet: u32,
column: i32,
old_value: Box<Option<Style>>,
new_value: Box<Style>,
},
SetRowStyle {
sheet: u32,
row: i32,
old_value: Box<Option<Style>>,
new_value: Box<Style>,
},
DeleteColumnStyle {
sheet: u32,
column: i32,
old_value: Box<Option<Style>>,
},
DeleteRowStyle {
sheet: u32,
row: i32,
old_value: Box<Option<Style>>,
},
InsertRow {
sheet: u32,
row: i32,
@@ -77,6 +105,10 @@ pub(crate) enum Diff {
column: i32,
old_data: Box<ColumnData>,
},
DeleteSheet {
sheet: u32,
old_data: Box<Worksheet>,
},
SetFrozenRowsCount {
sheet: u32,
new_value: i32,
@@ -87,9 +119,6 @@ pub(crate) enum Diff {
new_value: i32,
old_value: i32,
},
DeleteSheet {
sheet: u32,
},
NewSheet {
index: u32,
name: String,
@@ -168,11 +197,6 @@ impl History {
None => None,
}
}
pub fn clear(&mut self) {
self.redo_stack = vec![];
self.undo_stack = vec![];
}
}
#[derive(Clone, Encode, Decode)]

View File

@@ -1,5 +1,6 @@
#![deny(missing_docs)]
mod border;
mod border_utils;
mod common;
mod history;

View File

@@ -108,37 +108,120 @@ impl Worksheet {
self.cols = vec![Col {
min: 1,
max: constants::LAST_COLUMN,
width: constants::DEFAULT_COLUMN_WIDTH / constants::COLUMN_WIDTH_FACTOR,
custom_width: true,
width: constants::DEFAULT_COLUMN_WIDTH,
custom_width: false,
style: Some(style_index),
}];
Ok(())
}
pub fn set_column_style(&mut self, column: i32, style_index: i32) -> Result<(), String> {
let width = constants::DEFAULT_COLUMN_WIDTH / constants::COLUMN_WIDTH_FACTOR;
let width = self
.get_column_width(column)
.unwrap_or(constants::DEFAULT_COLUMN_WIDTH);
self.set_column_width_and_style(column, width, Some(style_index))
}
pub fn set_row_style(&mut self, row: i32, style_index: i32) -> Result<(), String> {
// FIXME: This is a HACK
let custom_format = style_index != 0;
for r in self.rows.iter_mut() {
if r.r == row {
r.s = style_index;
r.custom_format = true;
r.custom_format = custom_format;
return Ok(());
}
}
self.rows.push(Row {
height: constants::DEFAULT_ROW_HEIGHT / constants::ROW_HEIGHT_FACTOR,
r: row,
custom_format: true,
custom_height: true,
custom_format,
custom_height: false,
s: style_index,
hidden: false,
});
Ok(())
}
pub fn delete_row_style(&mut self, row: i32) -> Result<(), String> {
let mut index = None;
for (i, r) in self.rows.iter().enumerate() {
if r.r == row {
index = Some(i);
break;
}
}
if let Some(i) = index {
if let Some(r) = self.rows.get_mut(i) {
r.s = 0;
r.custom_format = false;
}
}
Ok(())
}
pub fn delete_column_style(&mut self, column: i32) -> Result<(), String> {
if !is_valid_column_number(column) {
return Err(format!("Column number '{column}' is not valid."));
}
let cols = &mut self.cols;
let mut index = 0;
let mut split = false;
for c in cols.iter_mut() {
let min = c.min;
let max = c.max;
if min <= column && column <= max {
//
split = true;
break;
}
if column < min {
// We passed, there is nothing to delete
break;
}
index += 1;
}
if split {
let min = cols[index].min;
let max = cols[index].max;
let custom_width = cols[index].custom_width;
let width = cols[index].width;
let pre = Col {
min,
max: column - 1,
width,
custom_width,
style: cols[index].style,
};
let col = Col {
min: column,
max: column,
width,
custom_width,
style: None,
};
let post = Col {
min: column + 1,
max,
width,
custom_width,
style: cols[index].style,
};
cols.remove(index);
if column != max {
cols.insert(index, post);
}
if custom_width {
cols.insert(index, col);
}
if column != min {
cols.insert(index, pre);
}
}
Ok(())
}
pub fn set_cell_style(
&mut self,
row: i32,
@@ -285,11 +368,12 @@ impl Worksheet {
/// Changes the width of a column.
/// * If the column does not a have a width we simply add it
/// * If it has, it might be part of a range and we ned to split the range.
/// * If it has, it might be part of a range and we need to split the range.
///
/// Fails if column index is outside allowed range or width is negative.
pub fn set_column_width(&mut self, column: i32, width: f64) -> Result<(), String> {
self.set_column_width_and_style(column, width, None)
let style = self.get_column_style(column)?;
self.set_column_width_and_style(column, width, style)
}
pub(crate) fn set_column_width_and_style(
@@ -309,7 +393,7 @@ impl Worksheet {
min: column,
max: column,
width: width / constants::COLUMN_WIDTH_FACTOR,
custom_width: true,
custom_width: width != constants::DEFAULT_COLUMN_WIDTH,
style,
};
let mut index = 0;
@@ -319,7 +403,9 @@ impl Worksheet {
let max = c.max;
if min <= column && column <= max {
if min == column && max == column {
c.style = style;
c.width = width / constants::COLUMN_WIDTH_FACTOR;
c.custom_width = width != constants::DEFAULT_COLUMN_WIDTH;
return Ok(());
}
split = true;
@@ -383,6 +469,23 @@ impl Worksheet {
Ok(constants::DEFAULT_COLUMN_WIDTH)
}
/// Returns the column style index if present
pub fn get_column_style(&self, column: i32) -> Result<Option<i32>, String> {
if !is_valid_column_number(column) {
return Err(format!("Column number '{column}' is not valid."));
}
let cols = &self.cols;
for col in cols {
let min = col.min;
let max = col.max;
if column >= min && column <= max {
return Ok(col.style);
}
}
Ok(None)
}
// Returns non empty cells in a column
pub fn column_cell_references(&self, column: i32) -> Result<Vec<CellReferenceIndex>, String> {
let mut column_cell_references: Vec<CellReferenceIndex> = Vec::new();

View File

@@ -1,7 +1,7 @@
[package]
edition = "2021"
name = "ironcalc_nodejs"
version = "0.3.1"
version = "0.5.0"
[lib]
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
napi = { version = "2.12.2", default-features = false, features = ["napi4", "serde-json"] }
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"] }
[build-dependencies]

View File

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

View File

@@ -161,6 +161,28 @@ impl UserModel {
self.model.range_clear_contents(&range).map_err(to_js_error)
}
#[napi(js_name = "rangeClearFormatting")]
pub fn range_clear_formatting(
&mut self,
sheet: u32,
start_row: i32,
start_column: i32,
end_row: i32,
end_column: i32,
) -> Result<()> {
let range = Area {
sheet,
row: start_row,
column: start_column,
width: end_column - start_column + 1,
height: end_row - start_row + 1,
};
self
.model
.range_clear_formatting(&range)
.map_err(to_js_error)
}
#[napi(js_name = "insertRow")]
pub fn insert_row(&mut self, sheet: u32, row: i32) -> Result<()> {
self.model.insert_row(sheet, row).map_err(to_js_error)
@@ -181,19 +203,31 @@ impl UserModel {
self.model.delete_column(sheet, column).map_err(to_js_error)
}
#[napi(js_name = "setRowHeight")]
pub fn set_row_height(&mut self, sheet: u32, row: i32, height: f64) -> Result<()> {
#[napi(js_name = "setRowsHeight")]
pub fn set_rows_height(
&mut self,
sheet: u32,
row_start: i32,
row_end: i32,
height: f64,
) -> Result<()> {
self
.model
.set_row_height(sheet, row, height)
.set_rows_height(sheet, row_start, row_end, height)
.map_err(to_js_error)
}
#[napi(js_name = "setColumnWidth")]
pub fn set_column_width(&mut self, sheet: u32, column: i32, width: f64) -> Result<()> {
#[napi(js_name = "setColumnsWidth")]
pub fn set_columns_width(
&mut self,
sheet: u32,
column_start: i32,
column_end: i32,
width: f64,
) -> Result<()> {
self
.model
.set_column_width(sheet, column, width)
.set_columns_width(sheet, column_start, column_end, width)
.map_err(to_js_error)
}

View File

@@ -1,6 +1,6 @@
[package]
name = "pyroncalc"
version = "0.3.0"
version = "0.5.0"
edition = "2021"
@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
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"] }

View File

@@ -0,0 +1,210 @@
API Reference
-------------
In general methods in IronCalc use a 0-index base for the the sheet index and 1-index base for the row and column indexes.
.. method:: evaluate()
Evaluates the model. This needs to be done after each change, otherwise the model might be on a broken state.
.. method:: set_user_input(sheet: int, row: int, column: int, value: str)
Sets an input in a cell, as would be done by a user typing into a spreadsheet cell.
:param sheet: The sheet index (0-based).
:param row: The 1-based row index (first row is 1).
:param column: The 1-based column index (column “A” is 1).
:param value: The value to set, e.g. ``"123"`` or ``"=A1*2"``.
.. method:: clear_cell_contents(sheet: int, row: int, column: int)
Removes the content of the cell but leaves the style intact.
:param sheet: The sheet index (0-based).
:param row: The 1-based row index (first row is 1).
:param column: The 1-based column index (column “A” is 1).
.. method:: get_cell_content(sheet: int, row: int, column: int) -> str
Returns the raw content of a cell. If the cell contains a formula,
the returned string starts with ``"="``.
:param sheet: The sheet index (0-based).
:param row: The 1-based row index.
:param column: The 1-based column index.
:returns: The raw content, or an empty string if the cell is empty.
.. method:: get_cell_type(sheet: int, row: int, column: int) -> PyCellType
Returns the type of the cell (number, boolean, string, error, etc.).
:param sheet: The sheet index (0-based).
:param row: The 1-based row index.
:param column: The 1-based column index.
:rtype: PyCellType
.. method:: get_formatted_cell_value(sheet: int, row: int, column: int) -> str
Returns the cells value as a formatted string, taking into
account any number/currency/date formatting.
:param sheet: The sheet index (0-based).
:param row: The 1-based row index.
:param column: The 1-based column index.
:returns: Formatted string of the cells value.
.. method:: set_cell_style(sheet: int, row: int, column: int, style: PyStyle)
Sets the style of the cell at (sheet, row, column).
:param sheet: The sheet index (0-based).
:param row: The 1-based row index.
:param column: The 1-based column index.
:param style: A PyStyle object specifying the style.
.. method:: get_cell_style(sheet: int, row: int, column: int) -> PyStyle
Retrieves the style of the specified cell.
:param sheet: The sheet index (0-based).
:param row: The 1-based row index.
:param column: The 1-based column index.
:returns: A PyStyle object describing the cells style.
.. method:: insert_rows(sheet: int, row: int, row_count: int)
Inserts new rows.
:param sheet: The sheet index (0-based).
:param row: The position before which new rows are inserted (1-based).
:param row_count: The number of rows to insert.
.. method:: insert_columns(sheet: int, column: int, column_count: int)
Inserts new columns.
:param sheet: The sheet index (0-based).
:param column: The position before which new columns are inserted (1-based).
:param column_count: The number of columns to insert.
.. method:: delete_rows(sheet: int, row: int, row_count: int)
Deletes a range of rows.
:param sheet: The sheet index (0-based).
:param row: The starting row to delete (1-based).
:param row_count: How many rows to delete.
.. method:: delete_columns(sheet: int, column: int, column_count: int)
Deletes a range of columns.
:param sheet: The sheet index (0-based).
:param column: The starting column to delete (1-based).
:param column_count: How many columns to delete.
.. method:: get_column_width(sheet: int, column: int) -> float
Retrieves the width of a given column.
:param sheet: The sheet index (0-based).
:param column: The 1-based column index.
:rtype: float
.. method:: get_row_height(sheet: int, row: int) -> float
Retrieves the height of a given row.
:param sheet: The sheet index (0-based).
:param row: The 1-based row index.
:rtype: float
.. method:: set_column_width(sheet: int, column: int, width: float)
Sets the width of a given column.
:param sheet: The sheet index (0-based).
:param column: The 1-based column index.
:param width: The desired width (float).
.. method:: set_row_height(sheet: int, row: int, height: float)
Sets the height of a given row.
:param sheet: The sheet index (0-based).
:param row: The 1-based row index.
:param height: The desired height (float).
.. method:: get_frozen_columns_count(sheet: int) -> int
Returns the number of columns frozen (pinned) on the left side of the sheet.
:param sheet: The sheet index (0-based).
:rtype: int
.. method:: get_frozen_rows_count(sheet: int) -> int
Returns the number of rows frozen (pinned) at the top of the sheet.
:param sheet: The sheet index (0-based).
:rtype: int
.. method:: set_frozen_columns_count(sheet: int, column_count: int)
Sets how many columns are frozen (pinned) on the left.
:param sheet: The sheet index (0-based).
:param column_count: The number of frozen columns (0-based).
.. method:: set_frozen_rows_count(sheet: int, row_count: int)
Sets how many rows are frozen (pinned) at the top.
:param sheet: The sheet index (0-based).
:param row_count: The number of frozen rows (0-based).
.. method:: get_worksheets_properties() -> List[PySheetProperty]
Returns a list of :class:`PySheetProperty` describing each worksheets
name, visibility state, ID, and tab color.
:rtype: list of PySheetProperty
.. method:: set_sheet_color(sheet: int, color: str)
Sets the tab color of a sheet. Use an empty string to clear the color.
:param sheet: The sheet index (0-based).
:param color: A color in “#RRGGBB” format, or empty to remove color.
.. method:: add_sheet(sheet_name: str)
Creates a new sheet with the specified name.
:param sheet_name: The name to give the new sheet.
.. method:: new_sheet()
Creates a new sheet with an auto-generated name.
.. method:: delete_sheet(sheet: int)
Deletes the sheet at the given index.
:param sheet: The sheet index (0-based).
.. method:: rename_sheet(sheet: int, new_name: str)
Renames the sheet at the given index.
:param sheet: The sheet index (0-based).
:param new_name: The new sheet name.
.. method:: test_panic()
A test method that deliberately panics in Rust.
Used for testing panic handling at the method level.
:raises WorkbookError: (wrapped Rust panic)

View File

@@ -1,13 +1,20 @@
IronCalc: The democratization of spreadsheets
=============================================
IronCalc
========
.. toctree::
:maxdepth: 2
:caption: Contents:
IronCalc is a spreadsheet engine that allows you to create, modify and safe spreadsheets.
installation
usage_examples
top_level_methods
api_reference
objects
IronCalc is a spreadsheet engine that allows you to create, modify and save spreadsheets.
A simple example that creates a model, sets a formula, evaluates it and gets the result back:
.. literalinclude:: examples/simple.py
:language: python

View File

@@ -0,0 +1,9 @@
Installation
------------
You can simply do:
.. code-block:: bash
pip install ironcalc

View File

@@ -0,0 +1,32 @@
Objects
-------
The following examples
``WorkbookError``
^^^^^^^^^^^^^^^^^
Exceptions of type ``WorkbookError`` are raised whenever there is a problem with
the workbook (e.g., invalid parameters, file I/O error, or even a Rust panic).
You can catch these exceptions in Python as follows:
.. code-block:: python
from ironcalc import WorkbookError
try:
# Some operation on PyModel
pass
except WorkbookError as e:
print("Caught a workbook error:", e)
``PyCellType``
^^^^^^^^^^^^^^
Represents the type of a cell (e.g., number, string, boolean, etc.). You can
check the type of a cell with :meth:`PyModel.get_cell_type`.
``PyStyle``
^^^^^^^^^^^
Represents the style of a cell (font, bold, number formats, alignment, etc.).
You can get/set these styles with :meth:`PyModel.get_cell_style`
and :meth:`PyModel.set_cell_style`.

View File

@@ -0,0 +1,6 @@
Top Level Methods
-----------------
.. autofunction:: ironcalc.create
.. autofunction:: ironcalc.load_from_xlsx
.. autofunction:: ironcalc.load_from_icalc

View File

@@ -0,0 +1,37 @@
Usage Examples
--------------
Creating an Empty Model
^^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: python
import ironcalc as ic
model = ic.create("My Workbook", "en", "UTC")
Loading from XLSX
^^^^^^^^^^^^^^^^^
.. code-block:: python
import ironcalc as ic
model = ic.load_from_xlsx("example.xlsx", "en", "UTC")
Modifying and Saving
^^^^^^^^^^^^^^^^^^^^
.. code-block:: python
model = ic.create("model", "en", "UTC")
model.set_user_input(0, 1, 1, "123")
model.set_user_input(0, 1, 2, "=A1*2")
model.evaluate()
# Save to XLSX
model.save_to_xlsx("updated.xlsx")
# Or save to the binary format
model.save_to_icalc("my_workbook.icalc")

View File

@@ -1,6 +1,6 @@
[project]
name = "ironcalc"
version = "0.3.0"
version = "0.5.0"
description = "Create, edit and evaluate Excel spreadsheets"
requires-python = ">=3.10"
keywords = [
@@ -16,7 +16,7 @@ classifiers = [
"Programming Language :: Python :: 3",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
"Office/Business :: Financial :: Spreadsheet",
"Topic :: Office/Business :: Financial :: Spreadsheet",
]
authors = [
{ name = "Nicolás Hatcher", email = "nicolas@theuniverse.today" },

View File

@@ -1,6 +1,6 @@
[package]
name = "wasm"
version = "0.3.0"
version = "0.5.0"
authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"]
description = "IronCalc Web bindings"
license = "MIT/Apache-2.0"
@@ -14,7 +14,7 @@ crate-type = ["cdylib"]
# Uses `../ironcalc/base` when used locally, and uses
# the inicated version from crates.io when published.
# 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"] }
wasm-bindgen = "0.2.92"
serde-wasm-bindgen = "0.4"

View File

@@ -7,6 +7,15 @@ https://www.npmjs.com/package/@ironcalc/wasm?activeTab=readme
## Building
Dependencies:
* Rust
* wasm-pack
* TypeScript
* Python
* binutils (for make)
```bash
make
```

View File

@@ -174,6 +174,27 @@ impl Model {
self.model.range_clear_contents(&range).map_err(to_js_error)
}
#[wasm_bindgen(js_name = "rangeClearFormatting")]
pub fn range_clear_formatting(
&mut self,
sheet: u32,
start_row: i32,
start_column: i32,
end_row: i32,
end_column: i32,
) -> Result<(), JsError> {
let range = Area {
sheet,
row: start_row,
column: start_column,
width: end_column - start_column + 1,
height: end_row - start_row + 1,
};
self.model
.range_clear_formatting(&range)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "insertRow")]
pub fn insert_row(&mut self, sheet: u32, row: i32) -> Result<(), JsError> {
self.model.insert_row(sheet, row).map_err(to_js_error)
@@ -194,17 +215,29 @@ impl Model {
self.model.delete_column(sheet, column).map_err(to_js_error)
}
#[wasm_bindgen(js_name = "setRowHeight")]
pub fn set_row_height(&mut self, sheet: u32, row: i32, height: f64) -> Result<(), JsError> {
#[wasm_bindgen(js_name = "setRowsHeight")]
pub fn set_rows_height(
&mut self,
sheet: u32,
row_start: i32,
row_end: i32,
height: f64,
) -> Result<(), JsError> {
self.model
.set_row_height(sheet, row, height)
.set_rows_height(sheet, row_start, row_end, height)
.map_err(to_js_error)
}
#[wasm_bindgen(js_name = "setColumnWidth")]
pub fn set_column_width(&mut self, sheet: u32, column: i32, width: f64) -> Result<(), JsError> {
#[wasm_bindgen(js_name = "setColumnsWidth")]
pub fn set_columns_width(
&mut self,
sheet: u32,
column_start: i32,
column_end: i32,
width: f64,
) -> Result<(), JsError> {
self.model
.set_column_width(sheet, column, width)
.set_columns_width(sheet, column_start, column_end, width)
.map_err(to_js_error)
}
@@ -271,6 +304,37 @@ impl Model {
.map_err(to_js_error)
}
// This two are only used when we want to compute the automatic width of a column or height of a row
#[wasm_bindgen(js_name = "getRowsWithData")]
pub fn get_rows_with_data(&self, sheet: u32, column: i32) -> Result<Vec<i32>, JsError> {
let sheet_data = &self
.model
.get_model()
.workbook
.worksheet(sheet)
.map_err(to_js_error)?
.sheet_data;
Ok(sheet_data
.iter()
.filter(|(_, data)| data.contains_key(&column))
.map(|(row, _)| *row)
.collect())
}
#[wasm_bindgen(js_name = "getColumnsWithData")]
pub fn get_columns_with_data(&self, sheet: u32, row: i32) -> Result<Vec<i32>, JsError> {
Ok(self
.model
.get_model()
.workbook
.worksheet(sheet)
.map_err(to_js_error)?
.sheet_data
.get(&row)
.map(|row_data| row_data.keys().copied().collect())
.unwrap_or_default())
}
#[wasm_bindgen(js_name = "updateRangeStyle")]
pub fn update_range_style(
&mut self,

View File

@@ -20,7 +20,7 @@ test('Row height', () => {
let model = new Model('Workbook1', 'en', 'UTC');
assert.strictEqual(model.getRowHeight(0, 3), DEFAULT_ROW_HEIGHT);
model.setRowHeight(0, 3, 32);
model.setRowsHeight(0, 3, 3, 32);
assert.strictEqual(model.getRowHeight(0, 3), 32);
model.undo();
@@ -29,7 +29,7 @@ test('Row height', () => {
model.redo();
assert.strictEqual(model.getRowHeight(0, 3), 32);
model.setRowHeight(0, 3, 320);
model.setRowsHeight(0, 3, 3, 320);
assert.strictEqual(model.getRowHeight(0, 3), 320);
});
@@ -49,7 +49,7 @@ test('Styles work', () => {
num_fmt: 'general',
fill: { pattern_type: 'none' },
font: {
sz: 11,
sz: 13,
color: '#000000',
name: 'Calibri',
family: 2,
@@ -64,7 +64,7 @@ test('Styles work', () => {
num_fmt: 'general',
fill: { pattern_type: 'none' },
font: {
sz: 11,
sz: 13,
color: '#000000',
name: 'Calibri',
family: 2,
@@ -96,7 +96,7 @@ test("Add sheets", (t) => {
test("invalid sheet index throws an exception", () => {
const model = new Model('Workbook1', 'en', 'UTC');
assert.throws(() => {
model.setRowHeight(1, 1, 100);
model.setRowsHeight(1, 1, 1, 100);
}, {
name: 'Error',
message: 'Invalid sheet index',
@@ -106,7 +106,7 @@ test("invalid sheet index throws an exception", () => {
test("invalid column throws an exception", () => {
const model = new Model('Workbook1', 'en', 'UTC');
assert.throws(() => {
model.setRowHeight(0, -1, 100);
model.setRowsHeight(0, -1, 0, 100);
}, {
name: 'Error',
message: "Row number '-1' is not valid.",
@@ -115,7 +115,7 @@ test("invalid column throws an exception", () => {
test("floating column numbers get truncated", () => {
const model = new Model('Workbook1', 'en', 'UTC');
model.setRowHeight(0.8, 5.2, 100.5);
model.setRowsHeight(0.8, 5.2, 5.5, 100.5);
assert.strictEqual(model.getRowHeight(0.11, 5.99), 100.5);
assert.strictEqual(model.getRowHeight(0, 5), 100.5);

View File

@@ -6,4 +6,4 @@ lang: en-US
# How to Contribute
If you want to give us a hand, take a look at our [GitHub repository](https://github.com/ironcalc/IronCalc?tab=readme-ov-file#collaborators-needed-call-to-action), join our [Discord Channel](https://discord.gg/sjaefMWE) or send us an email to [hello@ironcalc.com](mailto:hello@ironcalc.com).
If you want to give us a hand, take a look at our [GitHub repository](https://github.com/ironcalc/IronCalc?tab=readme-ov-file#collaborators-needed-call-to-action), join our [Discord Channel](https://discord.com/invite/zZYWfh3RHJ) or send us an email to [hello@ironcalc.com](mailto:hello@ironcalc.com).

View File

@@ -12,10 +12,6 @@ Although IronCalc is ready for use, its important to understand its current l
IronCalc currently does not implement arrays or array formulas. These are planned and are coming very soon, as they are the highest priority on the engine side.
## **Name Manager** <Badge type="info" text="Planned" />
While IronCalc supports importing and exporting defined names, it does not yet allow you to create, delete, or update them in the UI. This feature is expected to be implemented shortly.
## **Only English Supported**
The MVP version of IronCalc supports only the English language. However, version 1.0 will include support for three languages: **English**, **German**, and **Spanish**.

View File

@@ -6,7 +6,20 @@ lang: en-US
# Name Manager
::: warning
**Note:** This draft page is under construction 🚧
:::
The **Name Manager** makes working with specific cells or ranges easier by letting you assign custom names and set their scope.
## How to Use It
1. Click the **Named Ranges** button in the toolbar.
- A dialog will open.
2. Click **Add New**.
3. Input a name to identify the range.
4. Set the scope:
- **Global**: Applies to the entire workbook.
- **Sheet-specific**: Applies only to the selected sheet.
5. Click the check icon to save your changes.
## Managing Named Ranges
- Use **Edit** to modify name, range, or scope.
- Use **Delete** to remove ranges when theyre no longer needed.

View File

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

View File

@@ -14,9 +14,8 @@ npm install
## Local development
```bash
npm run dev
npm run storybook
```
## Linter and formatting
@@ -39,20 +38,9 @@ npm run test
Warning: There is only the testing infrastructure in place.
## Deploy
## Build package
Deploying is a bit of a manual hassle right now:
To build a deployable frontend:
```bash
npm run build
```
Please copy the `inroncalc.svg` icon and the models you want to have as 'examples' in the internal 'ic' format.
I normally compress the wasm and js files with brotli
```
brotli wasm_bg-*****.wasm
```
Copy to the final destination and you are good to go.

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,17 @@
{
"name": "@ironcalc/workbook",
"version": "0.3.1",
"version": "0.3.2",
"type": "module",
"main": "./dist/ironcalc.js",
"module": "./dist/ironcalc.js",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
"scripts": {
"dev": "vite",
"build": "vite build && tsc",
"check": "biome check ./src",
"check-write": "biome check --write ./src",
"test": "vitest run",
"storybook": "storybook dev -p 6006",
"storybook": "storybook dev -p 6006 --no-open",
"build-storybook": "storybook build"
},
"dependencies": {
@@ -27,29 +26,28 @@
"react-i18next": "^15.4.0"
},
"devDependencies": {
"@biomejs/biome": "1.8.3",
"@biomejs/biome": "1.9.4",
"@chromatic-com/storybook": "^3.2.4",
"@storybook/addon-essentials": "^8.6.0-alpha.0",
"@storybook/addon-interactions": "^8.6.0-alpha.0",
"@storybook/addon-onboarding": "^8.6.0-alpha.0",
"@storybook/blocks": "^8.6.0-alpha.0",
"@storybook/react": "^8.6.0-alpha.0",
"@storybook/react-vite": "^8.6.0-alpha.0",
"@storybook/test": "^8.6.0-alpha.0",
"@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",
"@vitejs/plugin-react": "^4.2.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"storybook": "^8.6.0-alpha.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"storybook": "^8.6.0",
"ts-node": "^10.9.2",
"typescript": "~5.6.2",
"vite": "^6.0.5",
"vite": "^6.2.0",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^2.0.5"
"vitest": "^3.0.7"
},
"peerDependencies": {
"@types/react": "^18.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"files": [
"dist"

View File

@@ -1,7 +1,7 @@
import "./index.css";
import type { Model } from "@ironcalc/wasm";
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 { theme } from "./theme.ts";
import "./i18n";

View File

@@ -20,15 +20,15 @@ import {
BorderRightIcon,
BorderStyleIcon,
BorderTopIcon,
} from "../icons";
import { theme } from "../theme";
import ColorPicker from "./colorPicker";
} from "../../icons";
import { theme } from "../../theme";
import ColorPicker from "../ColorPicker/ColorPicker";
type BorderPickerProps = {
className?: string;
onChange: (border: BorderOptions) => void;
onClose: () => void;
anchorEl: React.RefObject<HTMLElement>;
anchorEl: React.RefObject<HTMLElement | null>;
anchorOrigin?: PopoverOrigin;
transformOrigin?: PopoverOrigin;
open: boolean;

View File

@@ -1,33 +1,36 @@
import styled from "@emotion/styled";
import Popover, { type PopoverOrigin } from "@mui/material/Popover";
import { Check } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { HexColorInput, HexColorPicker } from "react-colorful";
import { theme } from "../theme";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
type ColorPickerProps = {
className?: string;
color: string;
onChange: (color: string) => void;
onClose: () => void;
anchorEl: React.RefObject<HTMLElement>;
anchorEl: React.RefObject<HTMLElement | null>;
anchorOrigin?: PopoverOrigin;
transformOrigin?: PopoverOrigin;
open: boolean;
};
const colorPickerWidth = 240;
const colorfulHeight = 185; // 150 + 15 + 20
const colorfulHeight = 240;
const ColorPicker = (properties: ColorPickerProps) => {
const [color, setColor] = useState<string>(properties.color);
const recentColors = useRef<string[]>([]);
const { t } = useTranslation();
const closePicker = (newColor: string): void => {
const maxRecentColors = 14;
properties.onChange(newColor);
const colors = recentColors.current.filter((c) => c !== newColor);
recentColors.current = [newColor, ...colors].slice(0, maxRecentColors);
properties.onChange(newColor);
};
const handleClose = (): void => {
@@ -85,21 +88,16 @@ const ColorPicker = (properties: ColorPickerProps) => {
/>
</HexColorInputBox>
</HexWrapper>
<Swatch
$color={color}
onClick={(): void => {
closePicker(color);
}}
/>
<Swatch $color={color} />
</ColorPickerInput>
<HorizontalDivider />
<ColorList>
{presetColors.map((presetColor) => (
<Button
<RecentColorButton
key={presetColor}
$color={presetColor}
onClick={(): void => {
closePicker(presetColor);
setColor(presetColor);
}}
/>
))}
@@ -111,11 +109,11 @@ const ColorPicker = (properties: ColorPickerProps) => {
<RecentLabel>{"Recent"}</RecentLabel>
<ColorList>
{recentColors.current.map((recentColor) => (
<Button
<RecentColorButton
key={recentColor}
$color={recentColor}
onClick={(): void => {
closePicker(recentColor);
setColor(recentColor);
}}
/>
))}
@@ -124,11 +122,46 @@ const ColorPicker = (properties: ColorPickerProps) => {
) : (
<div />
)}
<Buttons>
<StyledButton
onClick={(): void => {
closePicker(color);
}}
>
<Check
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
{t("color_picker.apply")}
</StyledButton>
</Buttons>
</ColorPickerDialog>
</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`
font-family: "Inter";
font-size: 12px;
@@ -146,7 +179,7 @@ const ColorList = styled.div`
gap: 4.7px;
`;
const Button = styled.button<{ $color: string }>`
const RecentColorButton = styled.button<{ $color: string }>`
width: 16px;
height: 16px;
${({ $color }): string => {
@@ -174,20 +207,6 @@ const HorizontalDivider = styled.div`
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`
background: ${theme.palette.background.default};
width: ${colorPickerWidth}px;

View File

@@ -9,7 +9,7 @@ interface Options {
onEditEnd: () => void;
onTextUpdated: () => void;
workbookState: WorkbookState;
textareaRef: RefObject<HTMLTextAreaElement>;
textareaRef: RefObject<HTMLTextAreaElement | null>;
}
export const useKeyDown = (

View File

@@ -5,6 +5,7 @@ import {
type TokenType,
getTokens,
} from "@ironcalc/wasm";
import type { JSX } from "react";
import type { ActiveRange } from "../workbookState";
function sliceString(

View File

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

View File

@@ -3,7 +3,7 @@ import { Dialog, TextField } from "@mui/material";
import { Check, X } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../theme";
import { theme } from "../../theme";
type FormatPickerProps = {
className?: string;
@@ -129,7 +129,6 @@ const StyledDialogContent = styled("div")`
const StyledTextField = styled(TextField)`
width: 100%;
min-width: 320px;
border-radius: 4px;
overflow: hidden;
& .MuiInputBase-input {

View File

@@ -1,13 +1,14 @@
import type { Model } from "@ironcalc/wasm";
import { styled } from "@mui/material";
import { Fx } from "../icons";
import { Fx } from "../../icons";
import { theme } from "../../theme";
import Editor from "../Editor/Editor";
import {
COLUMN_WIDTH_SCALE,
ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants";
import { FORMULA_BAR_HEIGHT } from "./constants";
import Editor from "./editor/editor";
import type { WorkbookState } from "./workbookState";
} from "../WorksheetCanvas/constants";
import { FORMULA_BAR_HEIGHT } from "../constants";
import type { WorkbookState } from "../workbookState";
type FormulaBarProps = {
cellAddress: string;
@@ -18,8 +19,6 @@ type FormulaBarProps = {
onTextUpdated: () => void;
};
const headerColumnWidth = 35;
function FormulaBar(properties: FormulaBarProps) {
const {
cellAddress,
@@ -99,7 +98,7 @@ const FormulaSymbolButton = styled(StyledButton)`
`;
const Divider = styled("div")`
background-color: #e0e0e0;
background-color: ${theme.palette.grey["300"]};
width: 1px;
height: 20px;
margin-left: 16px;
@@ -127,14 +126,13 @@ const Container = styled("div")`
const AddressContainer = styled("div")`
padding-left: 16px;
color: #333;
color: ${theme.palette.common.black};
font-style: normal;
font-weight: normal;
font-size: 11px;
font-size: 12px;
display: flex;
font-weight: 600;
flex-grow: row;
min-width: ${headerColumnWidth}px;
`;
const CellBarAddress = styled("div")`

View File

@@ -10,7 +10,7 @@ import {
styled,
} from "@mui/material";
import { t } from "i18next";
import { BookOpen, Plus, X } from "lucide-react";
import { BookOpen, PackageOpen, Plus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { theme } from "../../theme";
import NamedRangeActive from "./NamedRangeActive";
@@ -79,65 +79,79 @@ function NameManagerDialog(properties: NameManagerDialogProperties) {
</Cross>
</StyledDialogTitle>
<StyledDialogContent>
<StyledRangesHeader>
<StyledBox>{t("name_manager_dialog.name")}</StyledBox>
<StyledBox>{t("name_manager_dialog.range")}</StyledBox>
<StyledBox>{t("name_manager_dialog.scope")}</StyledBox>
</StyledRangesHeader>
<NameListWrapper>
{definedNameList.map((definedName, index) => {
const scopeName = definedName.scope
? worksheets[definedName.scope].name
: "[global]";
if (index === editingNameIndex) {
{(definedNameList.length > 0 || editingNameIndex !== -2) && (
<StyledRangesHeader>
<StyledBox>{t("name_manager_dialog.name")}</StyledBox>
<StyledBox>{t("name_manager_dialog.range")}</StyledBox>
<StyledBox>{t("name_manager_dialog.scope")}</StyledBox>
</StyledRangesHeader>
)}
{definedNameList.length === 0 && editingNameIndex === -2 ? (
<EmptyStateMessage>
<IconWrapper>
<PackageOpen />
</IconWrapper>
{t("name_manager_dialog.empty_message1")}
<br />
{t("name_manager_dialog.empty_message2")}
</EmptyStateMessage>
) : (
<NameListWrapper>
{definedNameList.map((definedName, index) => {
const scopeName =
definedName.scope !== undefined
? worksheets[definedName.scope].name
: "[global]";
if (index === editingNameIndex) {
return (
<NamedRangeActive
worksheets={worksheets}
name={definedName.name}
scope={scopeName}
formula={definedName.formula}
key={definedName.name + definedName.scope}
onSave={(
newName,
newScope,
newFormula,
): string | undefined => {
const scope_index = worksheets.findIndex(
(s) => s.name === newScope,
);
const scope = scope_index >= 0 ? scope_index : undefined;
try {
updateDefinedName(
definedName.name,
definedName.scope,
newName,
scope,
newFormula,
);
setEditingNameIndex(-2);
} catch (e) {
return `${e}`;
}
}}
onCancel={() => setEditingNameIndex(-2)}
/>
);
}
return (
<NamedRangeActive
worksheets={worksheets}
<NamedRangeInactive
name={definedName.name}
scope={scopeName}
formula={definedName.formula}
key={definedName.name + definedName.scope}
onSave={(
newName,
newScope,
newFormula,
): string | undefined => {
const scope_index = worksheets.findIndex(
(s) => s.name === newScope,
);
const scope = scope_index > 0 ? scope_index : undefined;
try {
updateDefinedName(
definedName.name,
definedName.scope,
newName,
scope,
newFormula,
);
setEditingNameIndex(-2);
} catch (e) {
return `${e}`;
}
showOptions={editingNameIndex === -2}
onEdit={() => setEditingNameIndex(index)}
onDelete={() => {
deleteDefinedName(definedName.name, definedName.scope);
}}
onCancel={() => setEditingNameIndex(-2)}
/>
);
}
return (
<NamedRangeInactive
name={definedName.name}
scope={scopeName}
formula={definedName.formula}
key={definedName.name + definedName.scope}
showOptions={editingNameIndex === -2}
onEdit={() => setEditingNameIndex(index)}
onDelete={() => {
deleteDefinedName(definedName.name, definedName.scope);
}}
/>
);
})}
</NameListWrapper>
})}
</NameListWrapper>
)}
{editingNameIndex === -1 && (
<NamedRangeActive
worksheets={worksheets}
@@ -230,6 +244,39 @@ const NameListWrapper = styled(Stack)`
overflow-y: auto;
`;
const EmptyStateMessage = styled(Box)`
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
height: 100%;
font-size: 12px;
color: ${theme.palette.grey["600"]};
font-family: "Inter";
z-index: 0;
margin: auto 0px;
position: relative;
`;
const IconWrapper = styled("div")`
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 4px;
background-color: ${theme.palette.grey["100"]};
color: ${theme.palette.grey["600"]};
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
`;
const StyledBox = styled(Box)`
display: flex;
flex-direction: column;

View File

@@ -3,8 +3,8 @@ import type { MenuItemProps } from "@mui/material";
import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react";
import { theme } from "../../theme";
import ColorPicker from "../colorPicker";
import { isInReferenceMode } from "../editor/util";
import ColorPicker from "../ColorPicker/ColorPicker";
import { isInReferenceMode } from "../Editor/util";
import type { WorkbookState } from "../workbookState";
import SheetDeleteDialog from "./SheetDeleteDialog";
import SheetRenameDialog from "./SheetRenameDialog";
@@ -26,7 +26,7 @@ function SheetTab(props: SheetTabProps) {
const { name, color, selected, workbookState, onSelected } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const colorButton = useRef(null);
const colorButton = useRef<HTMLDivElement>(null);
const open = Boolean(anchorEl);
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
@@ -57,12 +57,12 @@ function SheetTab(props: SheetTabProps) {
<TabWrapper
$color={color}
$selected={selected}
onClick={(event) => {
onClick={(event: React.MouseEvent) => {
onSelected();
event.stopPropagation();
event.preventDefault();
}}
onPointerDown={(event) => {
onPointerDown={(event: React.PointerEvent) => {
// If it is in browse mode stop he event
const cell = workbookState.getEditingCell();
if (cell && isInReferenceMode(cell.text, cell.cursorStart)) {

View File

@@ -3,8 +3,8 @@ import { Menu, Plus } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
import { StyledButton } from "../Toolbar/Toolbar";
import { NAVIGATION_HEIGHT } from "../constants";
import { StyledButton } from "../toolbar";
import type { WorkbookState } from "../workbookState";
import SheetListMenu from "./SheetListMenu";
import SheetTab from "./SheetTab";
@@ -123,6 +123,10 @@ const Container = styled("div")`
font-family: Inter;
background-color: ${theme.palette.common.white};
border-top: 1px solid ${theme.palette.grey["300"]};
@media (max-width: 769px) {
padding-right: 0px;
padding-left: 8px;
}
`;
const Sheets = styled("div")`
@@ -152,7 +156,7 @@ const Advert = styled("a")`
text-decoration: underline;
}
@media (max-width: 769px) {
height: 100%;
display: none;
}
`;
@@ -161,6 +165,9 @@ const LeftButtonsContainer = styled("div")`
flex-direction: row;
gap: 4px;
padding-right: 12px;
@media (max-width: 769px) {
padding-right: 8px;
}
`;
const VerticalDivider = styled("div")`

View File

@@ -18,16 +18,21 @@ import {
Grid2X2,
Grid2x2Check,
Grid2x2X,
ImageDown,
Italic,
Minus,
PaintBucket,
PaintRoller,
Percent,
Plus,
Redo2,
RemoveFormatting,
Strikethrough,
Tags,
Type,
Underline,
Undo2,
WrapText,
} from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -35,19 +40,19 @@ import {
ArrowMiddleFromLine,
DecimalPlacesDecreaseIcon,
DecimalPlacesIncreaseIcon,
} from "../icons";
import { theme } from "../theme";
import NameManagerDialog from "./NameManagerDialog";
import type { NameManagerProperties } from "./NameManagerDialog/NameManagerDialog";
import BorderPicker from "./borderPicker";
import ColorPicker from "./colorPicker";
import { TOOLBAR_HEIGHT } from "./constants";
import FormatMenu from "./formatMenu";
} from "../../icons";
import { theme } from "../../theme";
import BorderPicker from "../BorderPicker/BorderPicker";
import ColorPicker from "../ColorPicker/ColorPicker";
import FormatMenu from "../FormatMenu/FormatMenu";
import {
NumberFormats,
decreaseDecimalPlaces,
increaseDecimalPlaces,
} from "./formatUtil";
} from "../FormatMenu/formatUtil";
import NameManagerDialog from "../NameManagerDialog";
import type { NameManagerProperties } from "../NameManagerDialog/NameManagerDialog";
import { TOOLBAR_HEIGHT } from "../constants";
type ToolbarProperties = {
canUndo: boolean;
@@ -60,19 +65,25 @@ type ToolbarProperties = {
onToggleStrike: (v: boolean) => void;
onToggleHorizontalAlign: (v: string) => void;
onToggleVerticalAlign: (v: string) => void;
onToggleWrapText: (v: boolean) => void;
onCopyStyles: () => void;
onTextColorPicked: (hex: string) => void;
onFillColorPicked: (hex: string) => void;
onNumberFormatPicked: (numberFmt: string) => void;
onBorderChanged: (border: BorderOptions) => void;
onClearFormatting: () => void;
onIncreaseFontSize: (delta: number) => void;
onDownloadPNG: () => void;
fillColor: string;
fontColor: string;
fontSize: number;
bold: boolean;
underline: boolean;
italic: boolean;
strike: boolean;
horizontalAlign: HorizontalAlignment;
verticalAlign: VerticalAlignment;
wrapText: boolean;
canEdit: boolean;
numFmt: string;
showGridLines: boolean;
@@ -198,6 +209,30 @@ function Toolbar(properties: ToolbarProperties) {
</StyledButton>
</FormatMenu>
<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
type="button"
$pressed={properties.bold}
@@ -334,6 +369,17 @@ function Toolbar(properties: ToolbarProperties) {
>
<ArrowDownToLine />
</StyledButton>
<StyledButton
type="button"
$pressed={properties.wrapText === true}
onClick={() => {
properties.onToggleWrapText(!properties.wrapText);
}}
disabled={!canEdit}
title={t("toolbar.wrap_text")}
>
<WrapText />
</StyledButton>
<Divider />
<StyledButton
@@ -360,6 +406,30 @@ function Toolbar(properties: ToolbarProperties) {
<Tags />
</StyledButton>
<Divider />
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
onClick={() => {
properties.onClearFormatting();
}}
title={t("toolbar.clear_formatting")}
>
<RemoveFormatting />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
onClick={() => {
properties.onDownloadPNG();
}}
title={t("toolbar.selected_png")}
>
<ImageDown />
</StyledButton>
<ColorPicker
color={properties.fontColor}
onChange={(color): void => {
@@ -481,4 +551,16 @@ const Divider = styled("div")({
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;

View File

@@ -6,30 +6,35 @@ import type {
} from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
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 {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants";
} from "../WorksheetCanvas/constants";
import type WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import { devicePixelRatio } from "../WorksheetCanvas/worksheetCanvas";
import {
CLIPBOARD_ID_SESSION_STORAGE_KEY,
getNewClipboardId,
} from "./clipboard";
import FormulaBar from "./formulabar";
import Toolbar from "./toolbar";
import useKeyboardNavigation from "./useKeyboardNavigation";
} from "../clipboard";
import {
type NavigationKey,
getCellAddress,
getFullRangeToString,
} from "./util";
import type { WorkbookState } from "./workbookState";
import Worksheet from "./worksheet";
} from "../util";
import type { WorkbookState } from "../workbookState";
import useKeyboardNavigation from "./useKeyboardNavigation";
const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
const { model, workbookState } = props;
const rootRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const worksheetRef = useRef<{
getCanvas: () => WorksheetCanvas | null;
}>(null);
// Calling `setRedrawId((id) => id + 1);` forces a redraw
// This is needed because `model` or `workbookState` can change without React being aware of it
@@ -107,6 +112,10 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
updateRangeStyle("alignment.vertical", value);
};
const onToggleWrapText = (value: boolean) => {
updateRangeStyle("alignment.wrap_text", `${value}`);
};
const onTextColorPicked = (hex: string) => {
updateRangeStyle("font.color", hex);
};
@@ -119,6 +128,10 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
updateRangeStyle("num_fmt", numberFmt);
};
const onIncreaseFontSize = (delta: number) => {
updateRangeStyle("font.size_delta", `${delta}`);
};
const onCopyStyles = () => {
const {
sheet,
@@ -222,17 +235,17 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
},
onBold: () => {
const { sheet, row, column } = model.getSelectedView();
const value = !model.getCellStyle(sheet, row, column).font.b;
const value = model.getCellStyle(sheet, row, column).font.b;
onToggleBold(!value);
},
onItalic: () => {
const { sheet, row, column } = model.getSelectedView();
const value = !model.getCellStyle(sheet, row, column).font.i;
const value = model.getCellStyle(sheet, row, column).font.i;
onToggleItalic(!value);
},
onUnderline: () => {
const { sheet, row, column } = model.getSelectedView();
const value = !model.getCellStyle(sheet, row, column).font.u;
const value = model.getCellStyle(sheet, row, column).font.u;
onToggleUnderline(!value);
},
onNavigationToEdge: (direction: NavigationKey): void => {
@@ -350,7 +363,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
ref={rootRef}
onKeyDown={onKeyDown}
tabIndex={0}
onClick={(event) => {
onClick={(event: React.MouseEvent) => {
if (!workbookState.getEditingCell()) {
focusWorkbook();
} else {
@@ -523,10 +536,81 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
onToggleStrike={onToggleStrike}
onToggleHorizontalAlign={onToggleHorizontalAlign}
onToggleVerticalAlign={onToggleVerticalAlign}
onToggleWrapText={onToggleWrapText}
onCopyStyles={onCopyStyles}
onTextColorPicked={onTextColorPicked}
onFillColorPicked={onFillColorPicked}
onNumberFormatPicked={onNumberFormatPicked}
onClearFormatting={() => {
const {
sheet,
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
model.rangeClearFormatting(
sheet,
rowStart,
columnStart,
rowEnd,
columnEnd,
);
setRedrawId((id) => id + 1);
}}
onIncreaseFontSize={(delta: number) => {
onIncreaseFontSize(delta);
}}
onDownloadPNG={() => {
// creates a new canvas element in the visible part of the the selected area
const worksheetCanvas = worksheetRef.current?.getCanvas();
if (!worksheetCanvas) {
return;
}
const {
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
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 => {
const {
sheet,
@@ -549,6 +633,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}}
fillColor={style.fill.fg_color || "#FFFFFF"}
fontColor={style.font.color}
fontSize={style.font.sz}
bold={style.font.b}
underline={style.font.u}
italic={style.font.i}
@@ -559,6 +644,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
verticalAlign={
style.alignment?.vertical ? style.alignment.vertical : "bottom"
}
wrapText={style.alignment?.wrap_text || false}
canEdit={true}
numFmt={style.num_fmt}
showGridLines={model.getShowGridLines(model.getSelectedSheet())}
@@ -619,6 +705,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
refresh={(): void => {
setRedrawId((id) => id + 1);
}}
ref={worksheetRef}
/>
<SheetTabBar

View File

@@ -1,5 +1,5 @@
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 {
Top = "top",
@@ -32,7 +32,7 @@ interface Options {
onNextSheet: () => void;
onPreviousSheet: () => void;
onEscape: () => void;
root: RefObject<HTMLDivElement>;
root: RefObject<HTMLDivElement | null>;
}
// # IronCalc Keyboard accessibility:

View File

@@ -0,0 +1,289 @@
import { Menu, MenuItem, styled } from "@mui/material";
import {
BetweenHorizontalStart,
BetweenVerticalStart,
ChevronRight,
Snowflake,
Trash2,
} from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
const red_color = theme.palette.error.main;
interface CellContextMenuProps {
open: boolean;
onClose: () => void;
anchorEl: HTMLDivElement | null;
onInsertRowAbove: () => void;
onInsertRowBelow: () => void;
onInsertColumnLeft: () => void;
onInsertColumnRight: () => void;
onFreezeColumns: () => void;
onFreezeRows: () => void;
onUnfreezeColumns: () => void;
onUnfreezeRows: () => void;
onDeleteRow: () => void;
onDeleteColumn: () => void;
row: number;
column: string;
}
const CellContextMenu = (properties: CellContextMenuProps) => {
const { t } = useTranslation();
const {
open,
onClose,
anchorEl,
onInsertRowAbove,
onInsertRowBelow,
onInsertColumnLeft,
onInsertColumnRight,
onFreezeColumns,
onFreezeRows,
onUnfreezeColumns,
onUnfreezeRows,
onDeleteRow,
onDeleteColumn,
row,
column,
} = properties;
const [freezeMenuOpen, setFreezeMenuOpen] = useState(false);
const freezeRef = useRef(null);
const [insertRowMenuOpen, setInsertRowMenuOpen] = useState(false);
const insertRowRef = useRef(null);
const [insertColumnMenuOpen, setInsertColumnMenuOpen] = useState(false);
const insertColumnRef = useRef(null);
return (
<>
<StyledMenu
open={open}
onClose={onClose}
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: 6,
}}
>
<StyledMenuItem
ref={insertColumnRef}
onClick={() => setInsertColumnMenuOpen(true)}
>
<BetweenVerticalStartStyled />
<ItemNameStyled>{t("cell_context.insert_column")}</ItemNameStyled>
<ChevronRightStyled />
</StyledMenuItem>
<StyledMenuItem
ref={insertRowRef}
onClick={() => setInsertRowMenuOpen(true)}
>
<BetweenHorizontalStartStyled />
<ItemNameStyled>{t("cell_context.insert_row")}</ItemNameStyled>
<ChevronRightStyled />
</StyledMenuItem>
<MenuDivider />
<StyledMenuItem ref={freezeRef} onClick={() => setFreezeMenuOpen(true)}>
<StyledSnowflake />
<ItemNameStyled>{t("cell_context.freeze")}</ItemNameStyled>
<ChevronRightStyled />
</StyledMenuItem>
<MenuDivider />
<StyledMenuItem onClick={onDeleteRow}>
<StyledTrash />
<ItemNameStyled style={{ color: red_color }}>
{t("cell_context.delete_row", { row })}
</ItemNameStyled>
</StyledMenuItem>
<StyledMenuItem onClick={onDeleteColumn}>
<StyledTrash />
<ItemNameStyled style={{ color: red_color }}>
{t("cell_context.delete_column", { column })}
</ItemNameStyled>
</StyledMenuItem>
</StyledMenu>
<StyledMenu
open={insertRowMenuOpen}
onClose={() => setInsertRowMenuOpen(false)}
anchorEl={insertRowRef.current}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<StyledMenuItem
onClick={() => {
setInsertRowMenuOpen(false);
onInsertRowAbove();
}}
>
<ItemNameStyled>{t("cell_context.insert_row_above")}</ItemNameStyled>
</StyledMenuItem>
<StyledMenuItem
onClick={() => {
setInsertRowMenuOpen(false);
onInsertRowBelow();
}}
>
<ItemNameStyled>{t("cell_context.insert_row_below")}</ItemNameStyled>
</StyledMenuItem>
</StyledMenu>
<StyledMenu
open={insertColumnMenuOpen}
onClose={() => setInsertColumnMenuOpen(false)}
anchorEl={insertColumnRef.current}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<StyledMenuItem
onClick={() => {
setInsertColumnMenuOpen(false);
onInsertColumnLeft();
}}
>
<ItemNameStyled>
{t("cell_context.insert_column_before")}
</ItemNameStyled>
</StyledMenuItem>
<StyledMenuItem
onClick={() => {
setInsertColumnMenuOpen(false);
onInsertColumnRight();
}}
>
<ItemNameStyled>
{t("cell_context.insert_column_after")}
</ItemNameStyled>
</StyledMenuItem>
</StyledMenu>
<StyledMenu
open={freezeMenuOpen}
onClose={() => setFreezeMenuOpen(false)}
anchorEl={freezeRef.current}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<StyledMenuItem
onClick={() => {
onFreezeColumns();
setFreezeMenuOpen(false);
}}
>
<ItemNameStyled>
{t("cell_context.freeze_columns", { column })}
</ItemNameStyled>
</StyledMenuItem>
<StyledMenuItem
onClick={() => {
onFreezeRows();
setFreezeMenuOpen(false);
}}
>
<ItemNameStyled>
{t("cell_context.freeze_rows", { row })}
</ItemNameStyled>
</StyledMenuItem>
<MenuDivider />
<StyledMenuItem
onClick={() => {
onUnfreezeColumns();
setFreezeMenuOpen(false);
}}
>
<ItemNameStyled>{t("cell_context.unfreeze_columns")}</ItemNameStyled>
</StyledMenuItem>
<StyledMenuItem
onClick={() => {
onUnfreezeRows();
setFreezeMenuOpen(false);
}}
>
<ItemNameStyled>{t("cell_context.unfreeze_rows")}</ItemNameStyled>
</StyledMenuItem>
</StyledMenu>
</>
);
};
const BetweenVerticalStartStyled = styled(BetweenVerticalStart)`
width: 16px;
height: 16px;
color: ${theme.palette.grey[900]};
padding-right: 10px;
`;
const BetweenHorizontalStartStyled = styled(BetweenHorizontalStart)`
width: 16px;
height: 16px;
color: ${theme.palette.grey[900]};
padding-right: 10px;
`;
const StyledSnowflake = styled(Snowflake)`
width: 16px;
height: 16px;
color: ${theme.palette.grey[900]};
padding-right: 10px;
`;
const StyledTrash = styled(Trash2)`
width: 16px;
height: 16px;
color: ${red_color};
padding-right: 10px;
`;
const StyledMenu = styled(Menu)({
"& .MuiPaper-root": {
borderRadius: 8,
paddingTop: 4,
paddingBottom: 4,
},
"& .MuiList-padding": {
padding: 0,
},
});
const StyledMenuItem = styled(MenuItem)`
display: flex;
justify-content: flex-start;
font-size: 14px;
width: calc(100% - 8px);
min-width: 172px;
margin: 0px 4px;
border-radius: 4px;
padding: 8px;
height: 32px;
`;
const MenuDivider = styled("div")`
width: 100%;
margin: auto;
margin-top: 4px;
margin-bottom: 4px;
border-top: 1px solid ${theme.palette.grey[200]};
`;
const ItemNameStyled = styled("div")`
font-size: 12px;
color: ${theme.palette.grey[900]};
flex-grow: 2;
`;
const ChevronRightStyled = styled(ChevronRight)`
width: 16px;
height: 16px;
`;
export default CellContextMenu;

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,20 +1,23 @@
import type { Model } from "@ironcalc/wasm";
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 {
headerColumnWidth,
headerRowHeight,
} from "./WorksheetCanvas/worksheetCanvas";
import { isInReferenceMode } from "./editor/util";
import type { Cell } from "./types";
import { rangeToStr } from "./util";
import type { WorkbookState } from "./workbookState";
} from "../WorksheetCanvas/worksheetCanvas";
import type { Cell } from "../types";
import { rangeToStr } from "../util";
import type { WorkbookState } from "../workbookState";
interface PointerSettings {
canvasElement: RefObject<HTMLCanvasElement>;
canvasElement: RefObject<HTMLCanvasElement | null>;
worksheetCanvas: RefObject<WorksheetCanvas | null>;
worksheetElement: RefObject<HTMLDivElement>;
worksheetElement: RefObject<HTMLDivElement | null>;
onCellSelected: (cell: Cell, event: React.MouseEvent) => void;
onRowSelected: (row: number, shift: boolean) => void;
onColumnSelected: (column: number, shift: boolean) => void;
onAllSheetSelected: () => void;
onAreaSelecting: (cell: Cell) => void;
onAreaSelected: () => void;
onExtendToCell: (cell: Cell) => void;
@@ -116,6 +119,11 @@ const usePointer = (options: PointerSettings): PointerEvents => {
const onPointerDown = useCallback(
(event: PointerEvent) => {
const target = event.target as HTMLElement;
if (target !== null && target.className === "column-resize-handle") {
// we are resizing a column
return;
}
let x = event.clientX;
let y = event.clientY;
const {
@@ -125,6 +133,9 @@ const usePointer = (options: PointerSettings): PointerEvents => {
worksheetElement,
worksheetCanvas,
workbookState,
onRowSelected,
onColumnSelected,
onAllSheetSelected,
} = options;
const worksheet = worksheetCanvas.current;
const canvas = canvasElement.current;
@@ -143,7 +154,10 @@ const usePointer = (options: PointerSettings): PointerEvents => {
y < headerRowHeight ||
y > canvasRect.height
) {
if (
if (x < headerColumnWidth && y < headerRowHeight) {
// Click on the top left corner
onAllSheetSelected();
} else if (
x > 0 &&
x < headerColumnWidth &&
y > headerRowHeight &&
@@ -152,8 +166,18 @@ const usePointer = (options: PointerSettings): PointerEvents => {
// Click on a row number
const cell = worksheet.getCellByCoordinates(headerColumnWidth, y);
if (cell) {
// TODO
// Row selected
onRowSelected(cell.row, event.shiftKey);
}
} else if (
x > headerColumnWidth &&
x < canvasRect.width &&
y > 0 &&
y < headerRowHeight
) {
// Click on a column letter
const cell = worksheet.getCellByCoordinates(x, headerRowHeight);
if (cell) {
onColumnSelected(cell.column, event.shiftKey);
}
}
return;
@@ -240,7 +264,6 @@ const usePointer = (options: PointerSettings): PointerEvents => {
onPointerMove,
onPointerUp,
onPointerHandleDown,
// onContextMenu,
};
};

View File

@@ -1,6 +1,6 @@
import type { Model } 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 { WorkbookState } from "../workbookState";
import {
@@ -37,6 +37,7 @@ export interface CanvasSettings {
};
onColumnWidthChanges: (sheet: number, column: number, width: number) => void;
onRowHeightChanges: (sheet: number, row: number, height: number) => void;
refresh: () => void;
}
export const fonts = {
@@ -69,6 +70,52 @@ function hexToRGBA10Percent(colorHex: string): 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 (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 {
sheetWidth: number;
@@ -106,6 +153,8 @@ export default class WorksheetCanvas {
onRowHeightChanges: (sheet: number, row: number, height: number) => void;
refresh: () => void;
constructor(options: CanvasSettings) {
this.model = options.model;
this.sheetWidth = 0;
@@ -116,6 +165,7 @@ export default class WorksheetCanvas {
this.ctx = this.setContext();
this.workbookState = options.workbookState;
this.editor = options.elements.editor;
this.refresh = options.refresh;
this.cellOutline = options.elements.cellOutline;
this.cellOutlineHandle = options.elements.cellOutlineHandle;
@@ -349,7 +399,7 @@ export default class WorksheetCanvas {
? gridColor
: backgroundColor;
const fontSize = 13;
const fontSize = style.font?.sz || 13;
let font = `${fontSize}px ${defaultCellFontFamily}`;
let textColor = defaultTextColor;
if (style.font) {
@@ -367,6 +417,7 @@ export default class WorksheetCanvas {
if (style.alignment?.vertical) {
verticalAlign = style.alignment.vertical;
}
const wrapText = style.alignment?.wrap_text || false;
const context = this.ctx;
context.font = font;
@@ -492,9 +543,14 @@ export default class WorksheetCanvas {
context.rect(x, y, width, height);
context.clip();
// Is there any better parameter?
const lineHeight = 22;
const lines = fullText.split("\n");
// Is there any better to determine the line height?
const lineHeight = fontSize * 1.5;
const lines = computeWrappedLines(
fullText,
wrapText,
context,
width - padding,
);
const lineCount = lines.length;
lines.forEach((text, line) => {
@@ -577,23 +633,56 @@ export default class WorksheetCanvas {
let resizeHandleUp = (event: MouseEvent): void => {
div.style.opacity = "0";
this.columnGuide.style.display = "none";
document.removeEventListener("mousemove", resizeHandleMove);
document.removeEventListener("mouseup", resizeHandleUp);
document.removeEventListener("pointermove", resizeHandleMove);
document.removeEventListener("pointerup", resizeHandleUp);
const newColumnWidth = columnWidth + event.pageX - initPageX;
this.onColumnWidthChanges(
this.model.getSelectedSheet(),
column,
newColumnWidth,
);
if (newColumnWidth !== columnWidth) {
this.onColumnWidthChanges(
this.model.getSelectedSheet(),
column,
newColumnWidth,
);
}
};
resizeHandleUp = resizeHandleUp.bind(this);
div.addEventListener("mousedown", (event) => {
div.addEventListener("pointerdown", (event) => {
div.style.opacity = "1";
this.columnGuide.style.display = "block";
this.columnGuide.style.left = `${headerColumnWidth + x}px`;
initPageX = event.pageX;
document.addEventListener("mousemove", resizeHandleMove);
document.addEventListener("mouseup", resizeHandleUp);
document.addEventListener("pointermove", resizeHandleMove);
document.addEventListener("pointerup", resizeHandleUp);
});
div.addEventListener("dblclick", (event) => {
// This is tough. We should have a call like this.model.setAutofitColumn(sheet, column)
// but we can't do that because the back end knows nothing about the rendering engine.
const sheet = this.model.getSelectedSheet();
const rows = this.model.getRowsWithData(sheet, column);
let width = 0;
for (const row of rows) {
const fullText = this.model.getFormattedCellValue(sheet, row, column);
if (fullText === "") {
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");
for (const line of lines) {
const textWidth = this.ctx.measureText(line).width;
width = Math.max(width, textWidth);
}
}
// If the width is 0, we do nothing
if (width !== 0) {
// The +8 is so that the text is in the same position regardless of the horizontal alignment
this.model.setColumnsWidth(sheet, column, column, width + 8);
this.refresh();
}
event.stopPropagation();
});
}
@@ -615,20 +704,60 @@ export default class WorksheetCanvas {
let resizeHandleUp = (event: MouseEvent): void => {
div.style.opacity = "0";
this.rowGuide.style.display = "none";
document.removeEventListener("mousemove", resizeHandleMove);
document.removeEventListener("mouseup", resizeHandleUp);
const newRowHeight = rowHeight + event.pageY - initPageY - 1;
this.onRowHeightChanges(sheet, row, newRowHeight);
document.removeEventListener("pointermove", resizeHandleMove);
document.removeEventListener("pointerup", resizeHandleUp);
const newRowHeight = rowHeight + event.pageY - initPageY;
if (newRowHeight !== rowHeight) {
this.onRowHeightChanges(sheet, row, newRowHeight);
}
};
resizeHandleUp = resizeHandleUp.bind(this);
/* istanbul ignore next */
div.addEventListener("mousedown", (event) => {
div.addEventListener("pointerdown", (event) => {
event.stopPropagation();
div.style.opacity = "1";
this.rowGuide.style.display = "block";
this.rowGuide.style.top = `${y}px`;
initPageY = event.pageY;
document.addEventListener("mousemove", resizeHandleMove);
document.addEventListener("mouseup", resizeHandleUp);
document.addEventListener("pointermove", resizeHandleMove);
document.addEventListener("pointerup", resizeHandleUp);
});
div.addEventListener("dblclick", (event) => {
// This is tough. We should have a call like this.model.setAutofitRow(sheet, row)
// but we can't do that because the back end knows nothing about the rendering engine.
const sheet = this.model.getSelectedSheet();
const columns = this.model.getColumnsWithData(sheet, row);
let height = 0;
for (const column of columns) {
const fullText = this.model.getFormattedCellValue(sheet, row, column);
if (fullText === "") {
continue;
}
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;
// This is computed so that the y position of the text is independent of the vertical alignment
const textHeight = (lineCount - 1) * lineHeight + 8 + fontSize;
height = Math.max(height, textHeight);
}
// If the height is 0, we do nothing
if (height !== 0) {
this.model.setRowsHeight(sheet, row, row, height);
this.refresh();
}
event.stopPropagation();
});
}
@@ -1354,7 +1483,7 @@ export default class WorksheetCanvas {
let y = headerRowHeight + 0.5;
for (let row = 1; row <= frozenRows; row += 1) {
const rowHeight = this.getRowHeight(selectedSheet, row);
x = headerColumnWidth;
x = headerColumnWidth + 0.5;
for (let column = 1; column <= frozenColumns; column += 1) {
const columnWidth = this.getColumnWidth(selectedSheet, column);
this.renderCell(row, column, x, y, columnWidth, rowHeight);
@@ -1363,7 +1492,7 @@ export default class WorksheetCanvas {
y += rowHeight;
}
if (frozenRows === 0 && frozenColumns !== 0) {
x = headerColumnWidth;
x = headerColumnWidth + 0.5;
for (let column = 1; column <= frozenColumns; column += 1) {
x += this.getColumnWidth(selectedSheet, column);
}
@@ -1397,7 +1526,7 @@ export default class WorksheetCanvas {
const frozenX = x;
const frozenY = y;
// Draw frozen rows (top-right pane)
y = headerRowHeight;
y = headerRowHeight + 0.5;
for (let row = 1; row <= frozenRows; row += 1) {
x = frozenX;
const rowHeight = this.getRowHeight(selectedSheet, row);

View File

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

View File

@@ -1,6 +1,7 @@
import type { Area, Cell } from "./types";
import { type SelectedView, columnNameFromNumber } from "@ironcalc/wasm";
import { LAST_COLUMN, LAST_ROW } from "./WorksheetCanvas/constants";
/**
* Returns true if the keypress should start editing
@@ -34,11 +35,23 @@ export const getCellAddress = (selectedArea: Area, selectedCell: Cell) => {
selectedArea.rowStart === selectedArea.rowEnd &&
selectedArea.columnEnd === selectedArea.columnStart;
return isSingleCell
? `${columnNameFromNumber(selectedCell.column)}${selectedCell.row}`
: `${columnNameFromNumber(selectedArea.columnStart)}${
selectedArea.rowStart
}:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`;
if (isSingleCell) {
return `${columnNameFromNumber(selectedCell.column)}${selectedCell.row}`;
}
if (selectedArea.rowStart === 1 && selectedArea.rowEnd === LAST_ROW) {
return `${columnNameFromNumber(selectedArea.columnStart)}:${columnNameFromNumber(
selectedArea.columnEnd,
)}`;
}
if (
selectedArea.columnStart === 1 &&
selectedArea.columnEnd === LAST_COLUMN
) {
return `${selectedArea.rowStart}:${selectedArea.rowEnd}`;
}
return `${columnNameFromNumber(selectedArea.columnStart)}${
selectedArea.rowStart
}:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`;
};
export function rangeToStr(

View File

@@ -1,539 +0,0 @@
import type { Model } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
COLUMN_WIDTH_SCALE,
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 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;
}
model.setColumnWidth(sheet, column, width);
worksheetCanvas.current?.renderSheet();
},
onRowHeightChanges(sheet, row, height) {
if (height < 0) {
return;
}
model.setRowHeight(sheet, row, height);
worksheetCanvas.current?.renderSheet();
},
});
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,
// onContextMenu,
} = usePointer({
model,
workbookState,
refresh,
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}
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>
</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

@@ -3,6 +3,7 @@
"redo": "Redo",
"undo": "Undo",
"copy_styles": "Copy styles",
"clear_formatting": "Clear formatting",
"euro": "Format as Euro",
"percentage": "Format as Percentage",
"bold": "Bold",
@@ -15,6 +16,8 @@
"format_number": "Format number",
"font_color": "Font 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_decrease": "Decrease decimal places",
"show_hide_grid_lines": "Show/hide grid lines",
@@ -22,6 +25,8 @@
"vertical_align_bottom": "Align bottom",
"vertical_align_middle": " Align middle",
"vertical_align_top": "Align top",
"selected_png": "Export Selected area as PNG",
"wrap_text": "Wrap text",
"format_menu": {
"auto": "Auto",
"number": "Number",
@@ -70,7 +75,7 @@
},
"sheet_delete": {
"title": "Are you sure?",
"message": "The sheet '{{sheetName}}' will be permanently deleted. This action cannot be undone.",
"message": "The sheet '{{sheetName}}' will be deleted.",
"confirm": "Yes, delete sheet",
"cancel": "Cancel"
},
@@ -85,6 +90,8 @@
},
"name_manager_dialog": {
"title": "Named Ranges",
"empty_message1": "No named ranges added yet.",
"empty_message2": "Click on 'Add new' to add one.",
"name": "Name",
"range": "Scope",
"scope": "Range",
@@ -97,5 +104,23 @@
"edit": "Edit Range",
"apply": "Apply changes",
"discard": "Discard changes"
},
"cell_context": {
"insert_row_above": "Insert 1 row above",
"insert_row_below": "Insert 1 row below",
"insert_column_before": "Insert 1 column left",
"insert_column_after": "Insert 1 column right",
"freeze_columns": "Freeze up to column '{{column}}'",
"freeze_rows": "Freeze up to row '{{row}}'",
"unfreeze_rows": "Unfreeze rows",
"unfreeze_columns": "Unfreeze columns",
"delete_row": "Delete row '{{row}}'",
"delete_column": "Delete column '{{column}}'",
"freeze": "Freeze",
"insert_row": "Insert row",
"insert_column": "Insert column"
},
"color_picker": {
"apply": "Apply"
}
}

7
webapp/README.md Normal file
View File

@@ -0,0 +1,7 @@
# WebApp
The folder app.ironcalc.com contains the frontend and backend code deployed at https://app.ironcalc.com
The folder IronCalc contains the actual code for the spreadsheet widget as found in:
https://www.npmjs.com/package/@ironcalc/workbook

View File

@@ -4,4 +4,4 @@
reverse_proxy /api/* 127.0.0.1:8000
# everything else is the frontend
reverse_proxy :5173
reverse_proxy localhost:5173

View File

@@ -0,0 +1,5 @@
rm -rf dist/*
npm run build
cd dist/assets && brotli wasm* && brotli index-*
cd ..
scp -r * app.ironcalc.com:~/app/

File diff suppressed because it is too large Load Diff

View File

@@ -13,17 +13,17 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@ironcalc/workbook": "^0.3.1",
"@mui/material": "^6.3.1",
"lucide": "^0.469.0",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"@ironcalc/workbook": "file:../../IronCalc/",
"@mui/material": "^6.4",
"lucide-react": "^0.473.0",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@biomejs/biome": "1.8.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@biomejs/biome": "1.9.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.5",

View File

@@ -35,7 +35,6 @@ function DeleteWorkbookDialog(properties: DeleteWorkbookDialogProperties) {
properties.onClose();
}
}}
role="dialog"
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>

View File

@@ -1,13 +1,12 @@
import styled from "@emotion/styled";
import type { Model } from "@ironcalc/workbook";
import { IronCalcIcon, IronCalcLogo } from "@ironcalc/workbook";
import { CircleCheck } from "lucide-react";
import { useRef, useState } from "react";
// import { IronCalcIcon, IronCalcLogo } from "./../icons";
import { FileMenu } from "./FileMenu";
import { ShareButton } from "./ShareButton";
import ShareWorkbookDialog from "./ShareWorkbookDialog";
import { WorkbookTitle } from "./WorkbookTitle";
import { downloadModel, shareModel } from "./rpc";
import { downloadModel } from "./rpc";
import { updateNameSelectedWorkbook } from "./storage";
export function FileBar(properties: {
@@ -18,7 +17,8 @@ export function FileBar(properties: {
onDelete: () => void;
}) {
const hiddenInputRef = useRef<HTMLInputElement>(null);
const [toast, setToast] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<FileBarWrapper>
<StyledDesktopLogo />
@@ -53,37 +53,17 @@ export function FileBar(properties: {
type="text"
style={{ position: "absolute", left: -9999, top: -9999 }}
/>
<div style={{ marginLeft: "auto" }}>
{toast ? (
<Toast>
<CircleCheck style={{ width: 12 }} />
<span
style={{ marginLeft: 8, marginRight: 12, fontFamily: "Inter" }}
>
URL copied to clipboard
</span>
</Toast>
) : (
""
<div style={{ marginLeft: "auto" }} />
<DialogContainer>
<ShareButton onClick={() => setIsDialogOpen(true)} />
{isDialogOpen && (
<ShareWorkbookDialog
onClose={() => setIsDialogOpen(false)}
onModelUpload={properties.onModelUpload}
model={properties.model}
/>
)}
</div>
<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);
}}
/>
</DialogContainer>
</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")`
margin: 0px 8px 0px 16px;
height: 12px;
@@ -141,3 +113,17 @@ const FileBarWrapper = styled("div")`
position: relative;
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

@@ -110,14 +110,12 @@ export function FileMenu(props: {
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<>
<UploadFileDialog
onClose={() => {
setImportMenuOpen(false);
}}
onModelUpload={props.onModelUpload}
/>
</>
<UploadFileDialog
onClose={() => {
setImportMenuOpen(false);
}}
onModelUpload={props.onModelUpload}
/>
</Modal>
<Modal
open={isDeleteDialogOpen}
@@ -125,13 +123,11 @@ export function FileMenu(props: {
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<>
<DeleteWorkbookDialog
onClose={() => setDeleteDialogOpen(false)}
onConfirm={props.onDelete}
workbookName={selectedUuid ? models[selectedUuid] : ""}
/>
</>
<DeleteWorkbookDialog
onClose={() => setDeleteDialogOpen(false)}
onConfirm={props.onDelete}
workbookName={selectedUuid ? models[selectedUuid] : ""}
/>
</Modal>
</>
);

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

@@ -96,7 +96,6 @@ function UploadFileDialog(properties: {
<DialogWrapper
open={true}
tabIndex={0}
role="dialog"
onClose={handleClose}
onKeyDown={(event) => {
if (event.code === "Escape") {

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "ironcalc"
version = "0.3.0"
version = "0.5.0"
authors = ["Nicolás Hatcher <nicolas@theuniverse.today>"]
edition = "2021"
homepage = "https://www.ironcalc.com"
@@ -20,7 +20,7 @@ thiserror = "1.0"
# Uses `../base` when used locally, and uses
# the inicated version from crates.io when published.
# 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"
chrono = "0.4"
bitcode = "0.6.0"

View File

@@ -9,11 +9,9 @@
//!
//! ```toml
//! [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:
//!
//!

View File

@@ -9,7 +9,7 @@ use ironcalc::compare::{test_file, test_load_and_saving};
use ironcalc::export::save_to_xlsx;
use ironcalc::import::{load_from_icalc, load_from_xlsx, load_from_xlsx_bytes};
use ironcalc_base::types::{HorizontalAlignment, VerticalAlignment};
use ironcalc_base::Model;
use ironcalc_base::{Model, UserModel};
// This is a functional test.
// We check that the output of example.xlsx is what we expect.
@@ -496,3 +496,17 @@ fn test_documentation_xlsx() {
}
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_user_model() {
let temp_file_name = "temp_file_test_user_model.xlsx";
let mut model = UserModel::new_empty("my_model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "=1+1").unwrap();
// test we can use `get_model` to save the model
save_to_xlsx(model.get_model(), temp_file_name).unwrap();
fs::remove_file(temp_file_name).unwrap();
// we can still use the model afterwards
model.set_rows_height(0, 1, 1, 100.0).unwrap();
}