Compare commits

..

53 Commits

Author SHA1 Message Date
Daniel
3263522f13 UPDATE: Add a dialog when 'Share' buttons is clickled 2025-02-26 23:34:41 +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
Nicolás Hatcher
610b899f66 UPDATE: Add raw API to nodejs bindings 2025-01-28 20:26:39 +01:00
Nicolás Hatcher Andrés
24fb87721f Update npm.yml 2025-01-28 08:09:17 +01:00
Nicolás Hatcher
d3bc8b135c FIX: Try a boolean argument for the CI instead 2025-01-28 08:09:17 +01:00
Daniel González-Albo
0f6d311de2 Merge pull request #252 from ironcalc/fix/dani-delete-sheet
UPDATE: Deleting a sheet prompts a confirmation dialog
2025-01-28 01:11:46 +01:00
Daniel
0c15ae194d Fix: simplified code by having only 1 dynamic message 2025-01-26 19:00:00 +01:00
Daniel
20c4a596bf UPDATE: Localization 2025-01-21 01:21:03 +01:00
Daniel
f07a69260f UPDATE: Deleting a sheet prompts a confirmation dialog 2025-01-21 01:20:42 +01:00
95 changed files with 7421 additions and 4128 deletions

View File

@@ -12,11 +12,7 @@ permissions:
publish: publish:
description: "Publish to npm" description: "Publish to npm"
required: true required: true
type: choice type: boolean
options:
- yes
- no
default: "no"
defaults: defaults:
run: run:
working-directory: ./bindings/nodejs working-directory: ./bindings/nodejs
@@ -437,10 +433,11 @@ jobs:
shell: bash shell: bash
- name: Publish - name: Publish
run: | run: |
npm config set provenance true echo "${{ github.event.inputs.publish }}"
if [ "${{ github.event.inputs.publish }}" = "yes" ]; then if [ "${{ github.event.inputs.publish }}" = "true" ]; then
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
npm publish --access public npm publish --access public
echo "Published to npm"
else else
echo "Not a release, skipping publish" echo "Not a release, skipping publish"
fi fi

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@v3
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@v3
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@v3
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@v3
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 document server (Thanks Dani!)
- New function FORMULATEXT - New function FORMULATEXT
- Name Manager ([#212](https://github.com/ironcalc/IronCalc/pull/212) [#220](https://github.com/ironcalc/IronCalc/pull/220)) - 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
- Fixed several issues with pasting content - Fixed several issues with pasting content
- Fixed several issues with borders - Fixed several issues with borders
- Fixed bug where columns and rows could be resized to negative width and height, respectively - 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) ## [0.2.0] - 2024-11-06 (The HN release)

4
Cargo.lock generated
View File

@@ -448,7 +448,7 @@ dependencies = [
[[package]] [[package]]
name = "ironcalc_nodejs" name = "ironcalc_nodejs"
version = "0.3.0" version = "0.3.1"
dependencies = [ dependencies = [
"ironcalc", "ironcalc",
"napi", "napi",
@@ -1070,7 +1070,7 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "wasm" name = "wasm"
version = "0.3.0" version = "0.3.2"
dependencies = [ dependencies = [
"ironcalc_base", "ironcalc_base",
"serde", "serde",

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(()) Ok(())
} }

View File

@@ -1872,12 +1872,29 @@ impl Model {
} }
/// Returns the style for cell (`sheet`, `row`, `column`) /// 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> { 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_index = self.get_cell_style_index(sheet, row, column)?;
let style = self.workbook.styles.get_style(style_index)?; let style = self.workbook.styles.get_style(style_index)?;
Ok(style) 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 /// Returns an internal binary representation of the workbook
/// ///
/// See also: /// See also:
@@ -2161,6 +2178,73 @@ impl Model {
Err("Defined name not found".to_string()) 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)] #[cfg(test)]

View File

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

View File

@@ -4,8 +4,6 @@ use crate::{
types::{Border, CellStyles, CellXfs, Fill, Font, NumFmt, Style, Styles}, 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 { impl Styles {
fn get_font_index(&self, font: &Font) -> Option<i32> { fn get_font_index(&self, font: &Font) -> Option<i32> {
for (font_index, item) in self.fonts.iter().enumerate() { 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_model_is_empty_cell;
mod test_move_formula; mod test_move_formula;
mod test_quote_prefix; mod test_quote_prefix;
mod test_row_column_styles;
mod test_set_user_input; mod test_set_user_input;
mod test_sheet_markup; mod test_sheet_markup;
mod test_sheets; 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_autofill_rows;
mod test_border; mod test_border;
mod test_clear_cells; mod test_clear_cells;
mod test_column_style;
mod test_defined_names; mod test_defined_names;
mod test_delete_row_column_formatting;
mod test_diff_queue; mod test_diff_queue;
mod test_evaluation; mod test_evaluation;
mod test_general; mod test_general;
@@ -13,9 +15,11 @@ mod test_on_area_selection;
mod test_on_expand_selected_range; mod test_on_expand_selected_range;
mod test_on_paste_styles; mod test_on_paste_styles;
mod test_paste_csv; mod test_paste_csv;
mod test_recursive;
mod test_rename_sheet; mod test_rename_sheet;
mod test_row_column; mod test_row_column;
mod test_sheet_state; mod test_sheet_state;
mod test_sheets_undo_redo;
mod test_styles; mod test_styles;
mod test_to_from_bytes; mod test_to_from_bytes;
mod test_undo_redo; 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, 1, "=1 + 1").unwrap();
model.set_user_input(1, 1, 2, "=A1*3").unwrap(); model.set_user_input(1, 1, 2, "=A1*3").unwrap();
model model
.set_column_width(1, 5, 5.0 * DEFAULT_COLUMN_WIDTH) .set_columns_width(1, 5, 5, 5.0 * DEFAULT_COLUMN_WIDTH)
.unwrap(); .unwrap();
model.new_sheet().unwrap(); model.new_sheet().unwrap();
model.set_user_input(2, 1, 1, "=Sheet2!B1").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())); assert_eq!(model.get_formatted_cell_value(2, 1, 1), Ok("6".to_string()));
model.delete_sheet(1).unwrap(); model.delete_sheet(1).unwrap();
assert!(!model.can_undo());
assert!(!model.can_redo());
} }
#[test] #[test]

View File

@@ -520,14 +520,19 @@ fn borders_top() {
.unwrap(); .unwrap();
model.set_area_with_border(range, &border_area).unwrap(); model.set_area_with_border(range, &border_area).unwrap();
check_borders(&model); check_borders(&model);
for row in 5..9 { for row in 4..9 {
for column in 6..9 { for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap(); let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem { let border_item = BorderItem {
style: BorderStyle::Thin, style: BorderStyle::Thin,
color: Some("#FF5566".to_string()), 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 None
} else { } else {
Some(border_item.clone()) Some(border_item.clone())
@@ -537,7 +542,7 @@ fn borders_top() {
diagonal_down: false, diagonal_down: false,
left: None, left: None,
right: None, right: None,
top: Some(border_item.clone()), top,
bottom, bottom,
diagonal: None, diagonal: None,
}; };
@@ -647,12 +652,12 @@ fn borders_right() {
style: BorderStyle::Thin, style: BorderStyle::Thin,
color: Some("#FF5566".to_string()), color: Some("#FF5566".to_string()),
}; };
let left = if column == 6 { let left = if column != 9 {
None None
} else { } else {
Some(border_item.clone()) Some(border_item.clone())
}; };
let right = if column == 9 { let right = if column != 8 {
None None
} else { } else {
Some(border_item.clone()) Some(border_item.clone())
@@ -705,7 +710,7 @@ fn borders_bottom() {
color: Some("#FF5566".to_string()), color: Some("#FF5566".to_string()),
}; };
// The top will also have a value for all but the first one // The top will also have a value for all but the first one
let top = if row == 5 { let bottom = if row != 8 {
None None
} else { } else {
Some(border_item.clone()) Some(border_item.clone())
@@ -715,8 +720,8 @@ fn borders_bottom() {
diagonal_down: false, diagonal_down: false,
left: None, left: None,
right: None, right: None,
top, top: None,
bottom: Some(border_item.clone()), bottom,
diagonal: None, diagonal: None,
}; };
assert_eq!(style.border, expected_border); assert_eq!(style.border, expected_border);
@@ -751,18 +756,13 @@ fn borders_left() {
model.set_area_with_border(range, &border_area).unwrap(); model.set_area_with_border(range, &border_area).unwrap();
for row in 5..9 { 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 style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem { let border_item = BorderItem {
style: BorderStyle::Thin, style: BorderStyle::Thin,
color: Some("#FF5566".to_string()), color: Some("#FF5566".to_string()),
}; };
let left = if column == 5 { let left = if column != 6 {
None
} else {
Some(border_item.clone())
};
let right = if column == 8 {
None None
} else { } else {
Some(border_item.clone()) Some(border_item.clone())
@@ -771,13 +771,29 @@ fn borders_left() {
diagonal_up: false, diagonal_up: false,
diagonal_down: false, diagonal_down: false,
left, left,
right, right: None,
top: None, top: None,
bottom: None, bottom: None,
diagonal: None, diagonal: None,
}; };
assert_eq!(style.border, expected_border); 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, style: BorderStyle::Thin,
color: Some("#F2F2F2".to_string()), color: Some("#F2F2F2".to_string()),
}; };
assert_eq!( assert_eq!(model._get_cell_actual_border("C4").top, Some(border_item));
model._get_cell_actual_border("C4").bottom,
Some(border_item)
);
model.undo().unwrap(); 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()) 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() { fn send_queue() {
let mut model1 = UserModel::from_model(new_empty_model()); let mut model1 = UserModel::from_model(new_empty_model());
let width = model1.get_column_width(0, 3).unwrap() * 3.0; 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(); model1.set_user_input(0, 1, 2, "Hello IronCalc!").unwrap();
let send_queue = model1.flush_send_queue(); let send_queue = model1.flush_send_queue();
@@ -34,7 +34,7 @@ fn apply_external_diffs_wrong_str() {
fn queue_undo_redo() { fn queue_undo_redo() {
let mut model1 = UserModel::from_model(new_empty_model()); let mut model1 = UserModel::from_model(new_empty_model());
let width = model1.get_column_width(0, 3).unwrap() * 3.0; 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(); model1.set_user_input(0, 1, 2, "Hello IronCalc!").unwrap();
assert!(model1.undo().is_ok()); assert!(model1.undo().is_ok());
assert!(model1.redo().is_ok()); assert!(model1.redo().is_ok());
@@ -57,8 +57,8 @@ fn queue_undo_redo_multiple() {
// do a bunch of things // do a bunch of things
model1.set_frozen_columns_count(0, 5).unwrap(); model1.set_frozen_columns_count(0, 5).unwrap();
model1.set_frozen_rows_count(0, 6).unwrap(); model1.set_frozen_rows_count(0, 6).unwrap();
model1.set_column_width(0, 7, 300.0).unwrap(); model1.set_columns_width(0, 7, 7, 300.0).unwrap();
model1.set_row_height(0, 23, 123.0).unwrap(); model1.set_rows_height(0, 23, 23, 123.0).unwrap();
model1.set_user_input(0, 55, 55, "=42+8").unwrap(); model1.set_user_input(0, 55, 55, "=42+8").unwrap();
for row in 1..5 { 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) // Insert some data in row 5 (and change the style)
assert!(model.set_user_input(0, 5, 1, "100$").is_ok()); assert!(model.set_user_input(0, 5, 1, "100$").is_ok());
// Change the height of the column // 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 // remove the row
assert!(model.delete_row(0, 5).is_ok()); 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 // Insert some data in row 5 (and change the style) in E1
assert!(model.set_user_input(0, 1, 5, "100$").is_ok()); assert!(model.set_user_input(0, 1, 5, "100$").is_ok());
// Change the width of the column // 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); assert_eq!(model.get_column_width(0, 5).unwrap(), 3.0 * column_width);
// remove the column // 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, 1, 5, "3").unwrap();
model.set_user_input(0, 2, 5, "=E1*2").unwrap(); model.set_user_input(0, 2, 5, "=E1*2").unwrap();
model model
.set_column_width(0, 5, DEFAULT_COLUMN_WIDTH * 3.0) .set_columns_width(0, 5, 5, DEFAULT_COLUMN_WIDTH * 3.0)
.unwrap(); .unwrap();
model.delete_column(0, 5).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_user_input(0, 15, 6, "=D15*2").unwrap();
model model
.set_row_height(0, 15, DEFAULT_ROW_HEIGHT * 3.0) .set_rows_height(0, 15, 15, DEFAULT_ROW_HEIGHT * 3.0)
.unwrap(); .unwrap();
model.delete_row(0, 15).unwrap(); model.delete_row(0, 15).unwrap();
@@ -172,3 +172,42 @@ fn row_heigh_increases_automatically() {
.unwrap(); .unwrap();
assert_eq!(model.get_row_height(0, 1), Ok(2.0 * DEFAULT_ROW_HEIGHT)); assert_eq!(model.get_row_height(0, 1), Ok(2.0 * DEFAULT_ROW_HEIGHT));
} }
#[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(); let style = model.get_cell_style(0, 1, 1).unwrap();
assert!(!style.font.b); 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() { fn basic() {
let mut model1 = UserModel::from_model(new_empty_model()); let mut model1 = UserModel::from_model(new_empty_model());
let width = model1.get_column_width(0, 3).unwrap() * 3.0; 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(); model1.set_user_input(0, 1, 2, "Hello IronCalc!").unwrap();
let model_bytes = model1.to_bytes(); let model_bytes = model1.to_bytes();

View File

@@ -323,6 +323,19 @@ pub struct Style {
pub quote_prefix: bool, 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)] #[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
pub struct NumFmt { pub struct NumFmt {
pub num_fmt_id: i32, pub num_fmt_id: i32,
@@ -394,7 +407,7 @@ impl Default for Font {
u: false, u: false,
b: false, b: false,
i: false, i: false,
sz: 11, sz: 13,
color: Some("#000000".to_string()), color: Some("#000000".to_string()),
name: "Calibri".to_string(), name: "Calibri".to_string(),
family: 2, family: 2,

View File

@@ -50,8 +50,9 @@ impl Units {
fn get_units_from_format_string(num_fmt: &str) -> Option<Units> { fn get_units_from_format_string(num_fmt: &str) -> Option<Units> {
let mut parser = Parser::new(num_fmt); let mut parser = Parser::new(num_fmt);
parser.parse(); parser.parse();
let parts = parser.parts.first()?;
// We only care about the first part (positive number) // We only care about the first part (positive number)
match &parser.parts[0] { match parts {
ParsePart::Number(part) => { ParsePart::Number(part) => {
if part.percent > 0 { if part.percent > 0 {
Some(Units::Percentage { 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 bitcode::{Decode, Encode};
use crate::types::{Cell, Col, Row, SheetState, Style}; use crate::types::{Cell, Col, Row, SheetState, Style, Worksheet};
#[derive(Clone, Encode, Decode)] #[derive(Clone, Encode, Decode)]
pub(crate) struct RowData { pub(crate) struct RowData {
@@ -39,11 +39,17 @@ pub(crate) enum Diff {
old_value: Box<Option<Cell>>, old_value: Box<Option<Cell>>,
old_style: Box<Style>, old_style: Box<Style>,
}, },
CellClearFormatting {
sheet: u32,
row: i32,
column: i32,
old_style: Box<Option<Style>>,
},
SetCellStyle { SetCellStyle {
sheet: u32, sheet: u32,
row: i32, row: i32,
column: i32, column: i32,
old_value: Box<Style>, old_value: Box<Option<Style>>,
new_value: Box<Style>, new_value: Box<Style>,
}, },
// Column and Row diffs // Column and Row diffs
@@ -59,6 +65,28 @@ pub(crate) enum Diff {
new_value: f64, new_value: f64,
old_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 { InsertRow {
sheet: u32, sheet: u32,
row: i32, row: i32,
@@ -77,6 +105,10 @@ pub(crate) enum Diff {
column: i32, column: i32,
old_data: Box<ColumnData>, old_data: Box<ColumnData>,
}, },
DeleteSheet {
sheet: u32,
old_data: Box<Worksheet>,
},
SetFrozenRowsCount { SetFrozenRowsCount {
sheet: u32, sheet: u32,
new_value: i32, new_value: i32,
@@ -87,9 +119,6 @@ pub(crate) enum Diff {
new_value: i32, new_value: i32,
old_value: i32, old_value: i32,
}, },
DeleteSheet {
sheet: u32,
},
NewSheet { NewSheet {
index: u32, index: u32,
name: String, name: String,
@@ -168,11 +197,6 @@ impl History {
None => None, None => None,
} }
} }
pub fn clear(&mut self) {
self.redo_stack = vec![];
self.undo_stack = vec![];
}
} }
#[derive(Clone, Encode, Decode)] #[derive(Clone, Encode, Decode)]

View File

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

View File

@@ -29,7 +29,7 @@ impl Workbook {
} }
/// Returns the a list of defined names in the workbook with their scope /// Returns the a list of defined names in the workbook with their scope
pub(crate) fn get_defined_names_with_scope(&self) -> Vec<(String, Option<u32>, String)> { pub fn get_defined_names_with_scope(&self) -> Vec<(String, Option<u32>, String)> {
let sheet_id_index: Vec<u32> = self.worksheets.iter().map(|s| s.sheet_id).collect(); let sheet_id_index: Vec<u32> = self.worksheets.iter().map(|s| s.sheet_id).collect();
let defined_names = self let defined_names = self

View File

@@ -108,37 +108,120 @@ impl Worksheet {
self.cols = vec![Col { self.cols = vec![Col {
min: 1, min: 1,
max: constants::LAST_COLUMN, max: constants::LAST_COLUMN,
width: constants::DEFAULT_COLUMN_WIDTH / constants::COLUMN_WIDTH_FACTOR, width: constants::DEFAULT_COLUMN_WIDTH,
custom_width: true, custom_width: false,
style: Some(style_index), style: Some(style_index),
}]; }];
Ok(()) Ok(())
} }
pub fn set_column_style(&mut self, column: i32, style_index: i32) -> Result<(), String> { 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)) 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> { 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() { for r in self.rows.iter_mut() {
if r.r == row { if r.r == row {
r.s = style_index; r.s = style_index;
r.custom_format = true; r.custom_format = custom_format;
return Ok(()); return Ok(());
} }
} }
self.rows.push(Row { self.rows.push(Row {
height: constants::DEFAULT_ROW_HEIGHT / constants::ROW_HEIGHT_FACTOR, height: constants::DEFAULT_ROW_HEIGHT / constants::ROW_HEIGHT_FACTOR,
r: row, r: row,
custom_format: true, custom_format,
custom_height: true, custom_height: false,
s: style_index, s: style_index,
hidden: false, hidden: false,
}); });
Ok(()) 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( pub fn set_cell_style(
&mut self, &mut self,
row: i32, row: i32,
@@ -285,11 +368,12 @@ impl Worksheet {
/// Changes the width of a column. /// Changes the width of a column.
/// * If the column does not a have a width we simply add it /// * 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. /// 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> { 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( pub(crate) fn set_column_width_and_style(
@@ -309,7 +393,7 @@ impl Worksheet {
min: column, min: column,
max: column, max: column,
width: width / constants::COLUMN_WIDTH_FACTOR, width: width / constants::COLUMN_WIDTH_FACTOR,
custom_width: true, custom_width: width != constants::DEFAULT_COLUMN_WIDTH,
style, style,
}; };
let mut index = 0; let mut index = 0;
@@ -319,7 +403,9 @@ impl Worksheet {
let max = c.max; let max = c.max;
if min <= column && column <= max { if min <= column && column <= max {
if min == column && max == column { if min == column && max == column {
c.style = style;
c.width = width / constants::COLUMN_WIDTH_FACTOR; c.width = width / constants::COLUMN_WIDTH_FACTOR;
c.custom_width = width != constants::DEFAULT_COLUMN_WIDTH;
return Ok(()); return Ok(());
} }
split = true; split = true;
@@ -383,6 +469,23 @@ impl Worksheet {
Ok(constants::DEFAULT_COLUMN_WIDTH) 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 // Returns non empty cells in a column
pub fn column_cell_references(&self, column: i32) -> Result<Vec<CellReferenceIndex>, String> { pub fn column_cell_references(&self, column: i32) -> Result<Vec<CellReferenceIndex>, String> {
let mut column_cell_references: Vec<CellReferenceIndex> = Vec::new(); let mut column_cell_references: Vec<CellReferenceIndex> = Vec::new();

View File

@@ -1,7 +1,7 @@
[package] [package]
edition = "2021" edition = "2021"
name = "ironcalc_nodejs" name = "ironcalc_nodejs"
version = "0.3.0" version = "0.3.1"
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib"]

View File

@@ -1,11 +1,20 @@
import test from 'ava' import test from 'ava'
import { Model } from '../index.js'; import { UserModel, Model } from '../index.js';
test('sum from native', (t) => { test('User Model smoke test', (t) => {
const model = new Model("Workbook1", "en", "UTC"); const model = new UserModel("Workbook1", "en", "UTC");
model.setUserInput(0, 1, 1, "=1+1"); model.setUserInput(0, 1, 1, "=1+1");
t.is(model.getFormattedCellValue(0, 1, 1), '2'); t.is(model.getFormattedCellValue(0, 1, 1), '2');
}); });
test('Raw API smoke test', (t) => {
const model = new Model("Workbook1", "en", "UTC");
model.setUserInput(0, 1, 1, "=1+1");
model.evaluate();
t.is(model.getFormattedCellValue(0, 1, 1), '2');
});

Binary file not shown.

View File

@@ -5,7 +5,45 @@
export declare class Model { export declare class Model {
constructor(name: string, locale: string, timezone: string) constructor(name: string, locale: string, timezone: string)
static fromBytes(bytes: Uint8Array): Model static fromXlsx(filePath: string, locale: string, tz: string): Model
static fromIcalc(fileName: string): Model
saveToXlsx(file: string): void
saveToIcalc(file: string): void
evaluate(): void
setUserInput(sheet: number, row: number, column: number, value: string): void
clearCellContents(sheet: number, row: number, column: number): void
getCellContent(sheet: number, row: number, column: number): string
getCellType(sheet: number, row: number, column: number): number
getFormattedCellValue(sheet: number, row: number, column: number): string
setCellStyle(sheet: number, row: number, column: number, style: unknown): void
getCellStyle(sheet: number, row: number, column: number): unknown
insertRows(sheet: number, row: number, rowCount: number): void
insertColumns(sheet: number, column: number, columnCount: number): void
deleteRows(sheet: number, row: number, rowCount: number): void
deleteColumns(sheet: number, column: number, columnCount: number): void
getColumnWidth(sheet: number, column: number): number
getRowHeight(sheet: number, row: number): number
setColumnWidth(sheet: number, column: number, width: number): void
setRowHeight(sheet: number, row: number, height: number): void
getFrozenColumnsCount(sheet: number): number
getFrozenRowsCount(sheet: number): number
setFrozenColumnsCount(sheet: number, columnCount: number): void
setFrozenRowsCount(sheet: number, rowCount: number): void
getWorksheetsProperties(): unknown
setSheetColor(sheet: number, color: string): void
addSheet(sheetName: string): void
newSheet(): void
deleteSheet(sheet: number): void
renameSheet(sheet: number, newName: string): void
getDefinedNameList(): unknown
newDefinedName(name: string, scope: number | undefined | null, formula: string): void
updateDefinedName(name: string, scope: number | undefined | null, newName: string, newScope: number | undefined | null, newFormula: string): void
deleteDefinedName(name: string, scope?: number | undefined | null): void
testPanic(): void
}
export declare class UserModel {
constructor(name: string, locale: string, timezone: string)
static fromBytes(bytes: Uint8Array): UserModel
canUndo(): boolean canUndo(): boolean
canRedo(): boolean canRedo(): boolean
pauseEvaluation(): void pauseEvaluation(): void

View File

@@ -310,6 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`) throw new Error(`Failed to load native binding`)
} }
const { Model } = nativeBinding const { Model, UserModel } = nativeBinding
module.exports.Model = Model module.exports.Model = Model
module.exports.UserModel = UserModel

View File

@@ -1,11 +1,28 @@
import { Model } from './index.js' import { UserModel, Model } from './index.js'
const model = new Model("Workbook1", "en", "UTC");
model.setUserInput(0, 1, 1, "=1+1"); function testUserModel() {
let t = model.getFormattedCellValue(0, 1, 1); const model = new UserModel("Workbook1", "en", "UTC");
console.log('From native', t); model.setUserInput(0, 1, 1, "=1+1");
let t = model.getFormattedCellValue(0, 1, 1);
let t2 = model.getCellStyle(0, 1, 1); console.log('From native', t);
console.log('From native', t2);
let t2 = model.getCellStyle(0, 1, 1);
console.log('From native', t2);
}
function testModel() {
const model = Model.fromXlsx("example.xlsx", "en", "UTC");
const style = model.getCellStyle(0, 1, 6);
console.log(style);
const quantum = model.getFormattedCellValue(0, 14, 4);
console.log(quantum);
}
testUserModel();
testModel();

View File

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

View File

@@ -1,623 +1,8 @@
#![deny(clippy::all)]
use serde::Serialize;
#[macro_use] #[macro_use]
extern crate napi_derive; extern crate napi_derive;
use napi::{self, bindgen_prelude::*, JsUnknown, Result}; mod model;
mod user_model;
use ironcalc::base::{ pub use model::Model;
expressions::types::Area, pub use user_model::UserModel;
types::{CellType, Style},
BorderArea, ClipboardData, UserModel as BaseModel,
};
#[derive(Serialize)]
struct DefinedName {
name: String,
scope: Option<u32>,
formula: String,
}
fn to_js_error(error: String) -> Error {
Error::new(Status::Unknown, error)
}
#[napi]
pub struct Model {
model: BaseModel,
}
#[napi]
impl Model {
#[napi(constructor)]
pub fn new(name: String, locale: String, timezone: String) -> Result<Self> {
let model = BaseModel::new_empty(&name, &locale, &timezone).map_err(to_js_error)?;
Ok(Self { model })
}
#[napi(factory)]
pub fn from_bytes(bytes: &[u8]) -> Result<Model> {
let model = BaseModel::from_bytes(bytes).map_err(to_js_error)?;
Ok(Model { model })
}
pub fn undo(&mut self) -> Result<()> {
self.model.undo().map_err(to_js_error)
}
pub fn redo(&mut self) -> Result<()> {
self.model.redo().map_err(to_js_error)
}
#[napi(js_name = "canUndo")]
pub fn can_undo(&self) -> bool {
self.model.can_undo()
}
#[napi(js_name = "canRedo")]
pub fn can_redo(&self) -> bool {
self.model.can_redo()
}
#[napi(js_name = "pauseEvaluation")]
pub fn pause_evaluation(&mut self) {
self.model.pause_evaluation()
}
#[napi(js_name = "resumeEvaluation")]
pub fn resume_evaluation(&mut self) {
self.model.resume_evaluation()
}
pub fn evaluate(&mut self) {
self.model.evaluate();
}
#[napi(js_name = "flushSendQueue")]
pub fn flush_send_queue(&mut self) -> Vec<u8> {
self.model.flush_send_queue()
}
#[napi(js_name = "applyExternalDiffs")]
pub fn apply_external_diffs(&mut self, diffs: &[u8]) -> Result<()> {
self.model.apply_external_diffs(diffs).map_err(to_js_error)
}
#[napi(js_name = "getCellContent")]
pub fn get_cell_content(&self, sheet: u32, row: i32, column: i32) -> Result<String> {
self
.model
.get_cell_content(sheet, row, column)
.map_err(to_js_error)
}
#[napi(js_name = "newSheet")]
pub fn new_sheet(&mut self) -> Result<()> {
self.model.new_sheet().map_err(to_js_error)
}
#[napi(js_name = "deleteSheet")]
pub fn delete_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.delete_sheet(sheet).map_err(to_js_error)
}
#[napi(js_name = "hideSheet")]
pub fn hide_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.hide_sheet(sheet).map_err(to_js_error)
}
#[napi(js_name = "unhideSheet")]
pub fn unhide_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.unhide_sheet(sheet).map_err(to_js_error)
}
#[napi(js_name = "renameSheet")]
pub fn rename_sheet(&mut self, sheet: u32, name: String) -> Result<()> {
self.model.rename_sheet(sheet, &name).map_err(to_js_error)
}
#[napi(js_name = "setSheetColor")]
pub fn set_sheet_color(&mut self, sheet: u32, color: String) -> Result<()> {
self
.model
.set_sheet_color(sheet, &color)
.map_err(to_js_error)
}
#[napi(js_name = "rangeClearAll")]
pub fn range_clear_all(
&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_all(&range).map_err(to_js_error)
}
#[napi(js_name = "rangeClearContents")]
pub fn range_clear_contents(
&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_contents(&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)
}
#[napi(js_name = "insertColumn")]
pub fn insert_column(&mut self, sheet: u32, column: i32) -> Result<()> {
self.model.insert_column(sheet, column).map_err(to_js_error)
}
#[napi(js_name = "deleteRow")]
pub fn delete_row(&mut self, sheet: u32, row: i32) -> Result<()> {
self.model.delete_row(sheet, row).map_err(to_js_error)
}
#[napi(js_name = "deleteColumn")]
pub fn delete_column(&mut self, sheet: u32, column: i32) -> Result<()> {
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<()> {
self
.model
.set_row_height(sheet, row, height)
.map_err(to_js_error)
}
#[napi(js_name = "setColumnWidth")]
pub fn set_column_width(&mut self, sheet: u32, column: i32, width: f64) -> Result<()> {
self
.model
.set_column_width(sheet, column, width)
.map_err(to_js_error)
}
#[napi(js_name = "getRowHeight")]
pub fn get_row_height(&mut self, sheet: u32, row: i32) -> Result<f64> {
self.model.get_row_height(sheet, row).map_err(to_js_error)
}
#[napi(js_name = "getColumnWidth")]
pub fn get_column_width(&mut self, sheet: u32, column: i32) -> Result<f64> {
self
.model
.get_column_width(sheet, column)
.map_err(to_js_error)
}
#[napi(js_name = "setUserInput")]
pub fn set_user_input(&mut self, sheet: u32, row: i32, column: i32, input: String) -> Result<()> {
self
.model
.set_user_input(sheet, row, column, &input)
.map_err(to_js_error)
}
#[napi(js_name = "getFormattedCellValue")]
pub fn get_formatted_cell_value(&self, sheet: u32, row: i32, column: i32) -> Result<String> {
self
.model
.get_formatted_cell_value(sheet, row, column)
.map_err(to_js_error)
}
#[napi(js_name = "getFrozenRowsCount")]
pub fn get_frozen_rows_count(&self, sheet: u32) -> Result<i32> {
self.model.get_frozen_rows_count(sheet).map_err(to_js_error)
}
#[napi(js_name = "getFrozenColumnsCount")]
pub fn get_frozen_columns_count(&self, sheet: u32) -> Result<i32> {
self
.model
.get_frozen_columns_count(sheet)
.map_err(to_js_error)
}
#[napi(js_name = "setFrozenRowsCount")]
pub fn set_frozen_rows_count(&mut self, sheet: u32, count: i32) -> Result<()> {
self
.model
.set_frozen_rows_count(sheet, count)
.map_err(to_js_error)
}
#[napi(js_name = "setFrozenColumnsCount")]
pub fn set_frozen_columns_count(&mut self, sheet: u32, count: i32) -> Result<()> {
self
.model
.set_frozen_columns_count(sheet, count)
.map_err(to_js_error)
}
#[napi(js_name = "updateRangeStyle")]
pub fn update_range_style(
&mut self,
env: Env,
range: JsUnknown,
style_path: String,
value: String,
) -> Result<()> {
let range: Area = env
.from_js_value(range)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.update_range_style(&range, &style_path, &value)
.map_err(to_js_error)
}
#[napi(js_name = "getCellStyle")]
pub fn get_cell_style(
&mut self,
env: Env,
sheet: u32,
row: i32,
column: i32,
) -> Result<JsUnknown> {
let style = self
.model
.get_cell_style(sheet, row, column)
.map_err(to_js_error)?;
env
.to_js_value(&style)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "onPasteStyles")]
pub fn on_paste_styles(&mut self, env: Env, styles: JsUnknown) -> Result<()> {
let styles: &Vec<Vec<Style>> = &env
.from_js_value(styles)
.map_err(|e| to_js_error(e.to_string()))?;
self.model.on_paste_styles(styles).map_err(to_js_error)
}
#[napi(js_name = "getCellType")]
pub fn get_cell_type(&self, sheet: u32, row: i32, column: i32) -> Result<i32> {
Ok(
match self
.model
.get_cell_type(sheet, row, column)
.map_err(to_js_error)?
{
CellType::Number => 1,
CellType::Text => 2,
CellType::LogicalValue => 4,
CellType::ErrorValue => 16,
CellType::Array => 64,
CellType::CompoundData => 128,
},
)
}
// I don't _think_ serializing to JsUnknown can't fail
// FIXME: Remove this clippy directive
#[napi(js_name = "getWorksheetsProperties")]
#[allow(clippy::unwrap_used)]
pub fn get_worksheets_properties(&self, env: Env) -> JsUnknown {
env
.to_js_value(&self.model.get_worksheets_properties())
.unwrap()
}
#[napi(js_name = "getSelectedSheet")]
pub fn get_selected_sheet(&self) -> u32 {
self.model.get_selected_sheet()
}
#[napi(js_name = "getSelectedCell")]
pub fn get_selected_cell(&self) -> Vec<i32> {
let (sheet, row, column) = self.model.get_selected_cell();
vec![sheet as i32, row, column]
}
// I don't _think_ serializing to JsUnknown can't fail
// FIXME: Remove this clippy directive
#[napi(js_name = "getSelectedView")]
#[allow(clippy::unwrap_used)]
pub fn get_selected_view(&self, env: Env) -> JsUnknown {
env.to_js_value(&self.model.get_selected_view()).unwrap()
}
#[napi(js_name = "setSelectedSheet")]
pub fn set_selected_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.set_selected_sheet(sheet).map_err(to_js_error)
}
#[napi(js_name = "setSelectedCell")]
pub fn set_selected_cell(&mut self, row: i32, column: i32) -> Result<()> {
self
.model
.set_selected_cell(row, column)
.map_err(to_js_error)
}
#[napi(js_name = "setSelectedRange")]
pub fn set_selected_range(
&mut self,
start_row: i32,
start_column: i32,
end_row: i32,
end_column: i32,
) -> Result<()> {
self
.model
.set_selected_range(start_row, start_column, end_row, end_column)
.map_err(to_js_error)
}
#[napi(js_name = "setTopLeftVisibleCell")]
pub fn set_top_left_visible_cell(&mut self, top_row: i32, top_column: i32) -> Result<()> {
self
.model
.set_top_left_visible_cell(top_row, top_column)
.map_err(to_js_error)
}
#[napi(js_name = "setShowGridLines")]
pub fn set_show_grid_lines(&mut self, sheet: u32, show_grid_lines: bool) -> Result<()> {
self
.model
.set_show_grid_lines(sheet, show_grid_lines)
.map_err(to_js_error)
}
#[napi(js_name = "getShowGridLines")]
pub fn get_show_grid_lines(&mut self, sheet: u32) -> Result<bool> {
self.model.get_show_grid_lines(sheet).map_err(to_js_error)
}
#[napi(js_name = "autoFillRows")]
pub fn auto_fill_rows(&mut self, env: Env, source_area: JsUnknown, to_row: i32) -> Result<()> {
let area: Area = env
.from_js_value(source_area)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.auto_fill_rows(&area, to_row)
.map_err(to_js_error)
}
#[napi(js_name = "autoFillColumns")]
pub fn auto_fill_columns(
&mut self,
env: Env,
source_area: JsUnknown,
to_column: i32,
) -> Result<()> {
let area: Area = env
.from_js_value(source_area)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.auto_fill_columns(&area, to_column)
.map_err(to_js_error)
}
#[napi(js_name = "onArrowRight")]
pub fn on_arrow_right(&mut self) -> Result<()> {
self.model.on_arrow_right().map_err(to_js_error)
}
#[napi(js_name = "onArrowLeft")]
pub fn on_arrow_left(&mut self) -> Result<()> {
self.model.on_arrow_left().map_err(to_js_error)
}
#[napi(js_name = "onArrowUp")]
pub fn on_arrow_up(&mut self) -> Result<()> {
self.model.on_arrow_up().map_err(to_js_error)
}
#[napi(js_name = "onArrowDown")]
pub fn on_arrow_down(&mut self) -> Result<()> {
self.model.on_arrow_down().map_err(to_js_error)
}
#[napi(js_name = "onPageDown")]
pub fn on_page_down(&mut self) -> Result<()> {
self.model.on_page_down().map_err(to_js_error)
}
#[napi(js_name = "onPageUp")]
pub fn on_page_up(&mut self) -> Result<()> {
self.model.on_page_up().map_err(to_js_error)
}
#[napi(js_name = "setWindowWidth")]
pub fn set_window_width(&mut self, window_width: f64) {
self.model.set_window_width(window_width);
}
#[napi(js_name = "setWindowHeight")]
pub fn set_window_height(&mut self, window_height: f64) {
self.model.set_window_height(window_height);
}
#[napi(js_name = "getScrollX")]
pub fn get_scroll_x(&self) -> Result<f64> {
self.model.get_scroll_x().map_err(to_js_error)
}
#[napi(js_name = "getScrollY")]
pub fn get_scroll_y(&self) -> Result<f64> {
self.model.get_scroll_y().map_err(to_js_error)
}
#[napi(js_name = "onExpandSelectedRange")]
pub fn on_expand_selected_range(&mut self, key: String) -> Result<()> {
self
.model
.on_expand_selected_range(&key)
.map_err(to_js_error)
}
#[napi(js_name = "onAreaSelecting")]
pub fn on_area_selecting(&mut self, target_row: i32, target_column: i32) -> Result<()> {
self
.model
.on_area_selecting(target_row, target_column)
.map_err(to_js_error)
}
#[napi(js_name = "setAreaWithBorder")]
pub fn set_area_with_border(
&mut self,
env: Env,
area: JsUnknown,
border_area: JsUnknown,
) -> Result<()> {
let range: Area = env
.from_js_value(area)
.map_err(|e| to_js_error(e.to_string()))?;
let border: BorderArea = env
.from_js_value(border_area)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.set_area_with_border(&range, &border)
.map_err(|e| to_js_error(e.to_string()))?;
Ok(())
}
#[napi(js_name = "toBytes")]
pub fn to_bytes(&self) -> Vec<u8> {
self.model.to_bytes()
}
#[napi(js_name = "getName")]
pub fn get_name(&self) -> String {
self.model.get_name()
}
#[napi(js_name = "setName")]
pub fn set_name(&mut self, name: String) {
self.model.set_name(&name);
}
#[napi(js_name = "copyToClipboard")]
pub fn copy_to_clipboard(&self, env: Env) -> Result<JsUnknown> {
let data = self
.model
.copy_to_clipboard()
.map_err(|e| to_js_error(e.to_string()))?;
env
.to_js_value(&data)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "pasteFromClipboard")]
pub fn paste_from_clipboard(
&mut self,
env: Env,
source_sheet: u32,
source_range: JsUnknown,
clipboard: JsUnknown,
is_cut: bool,
) -> Result<()> {
let source_range: (i32, i32, i32, i32) = env
.from_js_value(source_range)
.map_err(|e| to_js_error(e.to_string()))?;
let clipboard: ClipboardData = env
.from_js_value(clipboard)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.paste_from_clipboard(source_sheet, source_range, &clipboard, is_cut)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "pasteCsvText")]
pub fn paste_csv_string(&mut self, env: Env, area: JsUnknown, csv: String) -> Result<()> {
let range: Area = env
.from_js_value(area)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.paste_csv_string(&range, &csv)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "getDefinedNameList")]
pub fn get_defined_name_list(&self, env: Env) -> Result<JsUnknown> {
let data: Vec<DefinedName> = self
.model
.get_defined_name_list()
.iter()
.map(|s| DefinedName {
name: s.0.to_owned(),
scope: s.1,
formula: s.2.to_owned(),
})
.collect();
env
.to_js_value(&data)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "newDefinedName")]
pub fn new_defined_name(
&mut self,
name: String,
scope: Option<u32>,
formula: String,
) -> Result<()> {
self
.model
.new_defined_name(&name, scope, &formula)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "updateDefinedName")]
pub fn update_defined_name(
&mut self,
name: String,
scope: Option<u32>,
new_name: String,
new_scope: Option<u32>,
new_formula: String,
) -> Result<()> {
self
.model
.update_defined_name(&name, scope, &new_name, new_scope, &new_formula)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "deleteDefinedName")]
pub fn delete_definedname(&mut self, name: String, scope: Option<u32>) -> Result<()> {
self
.model
.delete_defined_name(&name, scope)
.map_err(|e| to_js_error(e.to_string()))
}
}

View File

@@ -0,0 +1,343 @@
#![deny(clippy::all)]
use napi::{self, bindgen_prelude::*, JsUnknown, Result};
use serde::Serialize;
use ironcalc::{
base::{
types::{CellType, Style},
Model as BaseModel,
},
error::XlsxError,
export::{save_to_icalc, save_to_xlsx},
import::{load_from_icalc, load_from_xlsx},
};
#[derive(Serialize)]
struct DefinedName {
name: String,
scope: Option<u32>,
formula: String,
}
fn to_js_error(error: String) -> Error {
Error::new(Status::Unknown, error)
}
fn to_node_error(error: XlsxError) -> Error {
Error::new(Status::Unknown, error.to_string())
}
#[napi]
pub struct Model {
model: BaseModel,
}
#[napi]
impl Model {
#[napi(constructor)]
pub fn new(name: String, locale: String, timezone: String) -> Result<Self> {
let model = BaseModel::new_empty(&name, &locale, &timezone).map_err(to_js_error)?;
Ok(Self { model })
}
#[napi(factory)]
pub fn from_xlsx(file_path: String, locale: String, tz: String) -> Result<Model> {
let model = load_from_xlsx(&file_path, &locale, &tz)
.map_err(|error| Error::new(Status::Unknown, error.to_string()))?;
Ok(Self { model })
}
#[napi(factory)]
pub fn from_icalc(file_name: String) -> Result<Model> {
let model = load_from_icalc(&file_name)
.map_err(|error| Error::new(Status::Unknown, error.to_string()))?;
Ok(Self { model })
}
#[napi]
pub fn save_to_xlsx(&self, file: String) -> Result<()> {
save_to_xlsx(&self.model, &file).map_err(to_node_error)
}
#[napi]
pub fn save_to_icalc(&self, file: String) -> Result<()> {
save_to_icalc(&self.model, &file).map_err(to_node_error)
}
#[napi]
pub fn evaluate(&mut self) {
self.model.evaluate();
}
#[napi]
pub fn set_user_input(&mut self, sheet: u32, row: i32, column: i32, value: String) -> Result<()> {
self
.model
.set_user_input(sheet, row, column, value)
.map_err(to_js_error)
}
#[napi]
pub fn clear_cell_contents(&mut self, sheet: u32, row: i32, column: i32) -> Result<()> {
self
.model
.cell_clear_contents(sheet, row, column)
.map_err(to_js_error)
}
#[napi]
pub fn get_cell_content(&self, sheet: u32, row: i32, column: i32) -> Result<String> {
self
.model
.get_cell_content(sheet, row, column)
.map_err(to_js_error)
}
#[napi(js_name = "getCellType")]
pub fn get_cell_type(&self, sheet: u32, row: i32, column: i32) -> Result<i32> {
Ok(
match self
.model
.get_cell_type(sheet, row, column)
.map_err(to_js_error)?
{
CellType::Number => 1,
CellType::Text => 2,
CellType::LogicalValue => 4,
CellType::ErrorValue => 16,
CellType::Array => 64,
CellType::CompoundData => 128,
},
)
}
#[napi]
pub fn get_formatted_cell_value(&self, sheet: u32, row: i32, column: i32) -> Result<String> {
self
.model
.get_formatted_cell_value(sheet, row, column)
.map_err(to_js_error)
}
#[napi]
pub fn set_cell_style(
&mut self,
env: Env,
sheet: u32,
row: i32,
column: i32,
style: JsUnknown,
) -> Result<()> {
let style: Style = env
.from_js_value(style)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.set_cell_style(sheet, row, column, &style)
.map_err(to_js_error)
}
#[napi(js_name = "getCellStyle")]
pub fn get_cell_style(
&mut self,
env: Env,
sheet: u32,
row: i32,
column: i32,
) -> Result<JsUnknown> {
let style = self
.model
.get_style_for_cell(sheet, row, column)
.map_err(to_js_error)?;
env
.to_js_value(&style)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi]
pub fn insert_rows(&mut self, sheet: u32, row: i32, row_count: i32) -> Result<()> {
self
.model
.insert_rows(sheet, row, row_count)
.map_err(to_js_error)
}
#[napi]
pub fn insert_columns(&mut self, sheet: u32, column: i32, column_count: i32) -> Result<()> {
self
.model
.insert_columns(sheet, column, column_count)
.map_err(to_js_error)
}
#[napi]
pub fn delete_rows(&mut self, sheet: u32, row: i32, row_count: i32) -> Result<()> {
self
.model
.delete_rows(sheet, row, row_count)
.map_err(to_js_error)
}
#[napi]
pub fn delete_columns(&mut self, sheet: u32, column: i32, column_count: i32) -> Result<()> {
self
.model
.delete_columns(sheet, column, column_count)
.map_err(to_js_error)
}
#[napi]
pub fn get_column_width(&self, sheet: u32, column: i32) -> Result<f64> {
self
.model
.get_column_width(sheet, column)
.map_err(to_js_error)
}
#[napi]
pub fn get_row_height(&self, sheet: u32, row: i32) -> Result<f64> {
self.model.get_row_height(sheet, row).map_err(to_js_error)
}
#[napi]
pub fn set_column_width(&mut self, sheet: u32, column: i32, width: f64) -> Result<()> {
self
.model
.set_column_width(sheet, column, width)
.map_err(to_js_error)
}
#[napi]
pub fn set_row_height(&mut self, sheet: u32, row: i32, height: f64) -> Result<()> {
self
.model
.set_row_height(sheet, row, height)
.map_err(to_js_error)
}
#[napi]
pub fn get_frozen_columns_count(&self, sheet: u32) -> Result<i32> {
self
.model
.get_frozen_columns_count(sheet)
.map_err(to_js_error)
}
#[napi]
pub fn get_frozen_rows_count(&self, sheet: u32) -> Result<i32> {
self.model.get_frozen_rows_count(sheet).map_err(to_js_error)
}
#[napi]
pub fn set_frozen_columns_count(&mut self, sheet: u32, column_count: i32) -> Result<()> {
self
.model
.set_frozen_columns(sheet, column_count)
.map_err(to_js_error)
}
#[napi]
pub fn set_frozen_rows_count(&mut self, sheet: u32, row_count: i32) -> Result<()> {
self
.model
.set_frozen_rows(sheet, row_count)
.map_err(to_js_error)
}
// I don't _think_ serializing to JsUnknown can't fail
// FIXME: Remove this clippy directive
#[napi(js_name = "getWorksheetsProperties")]
#[allow(clippy::unwrap_used)]
pub fn get_worksheets_properties(&self, env: Env) -> JsUnknown {
env
.to_js_value(&self.model.get_worksheets_properties())
.unwrap()
}
#[napi]
pub fn set_sheet_color(&mut self, sheet: u32, color: String) -> Result<()> {
self
.model
.set_sheet_color(sheet, &color)
.map_err(to_js_error)
}
#[napi]
pub fn add_sheet(&mut self, sheet_name: String) -> Result<()> {
self.model.add_sheet(&sheet_name).map_err(to_js_error)
}
#[napi]
pub fn new_sheet(&mut self) {
self.model.new_sheet();
}
#[napi]
pub fn delete_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.delete_sheet(sheet).map_err(to_js_error)
}
#[napi]
pub fn rename_sheet(&mut self, sheet: u32, new_name: String) -> Result<()> {
self
.model
.rename_sheet_by_index(sheet, &new_name)
.map_err(to_js_error)
}
#[napi(js_name = "getDefinedNameList")]
pub fn get_defined_name_list(&self, env: Env) -> Result<JsUnknown> {
let data: Vec<DefinedName> = self
.model
.workbook
.get_defined_names_with_scope()
.iter()
.map(|s| DefinedName {
name: s.0.to_owned(),
scope: s.1,
formula: s.2.to_owned(),
})
.collect();
env
.to_js_value(&data)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "newDefinedName")]
pub fn new_defined_name(
&mut self,
name: String,
scope: Option<u32>,
formula: String,
) -> Result<()> {
self
.model
.new_defined_name(&name, scope, &formula)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "updateDefinedName")]
pub fn update_defined_name(
&mut self,
name: String,
scope: Option<u32>,
new_name: String,
new_scope: Option<u32>,
new_formula: String,
) -> Result<()> {
self
.model
.update_defined_name(&name, scope, &new_name, new_scope, &new_formula)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "deleteDefinedName")]
pub fn delete_definedname(&mut self, name: String, scope: Option<u32>) -> Result<()> {
self
.model
.delete_defined_name(&name, scope)
.map_err(|e| to_js_error(e.to_string()))
}
}

View File

@@ -0,0 +1,654 @@
#![deny(clippy::all)]
use serde::Serialize;
use napi::{self, bindgen_prelude::*, JsUnknown, Result};
use ironcalc::base::{
expressions::types::Area,
types::{CellType, Style},
BorderArea, ClipboardData, UserModel as BaseModel,
};
#[derive(Serialize)]
struct DefinedName {
name: String,
scope: Option<u32>,
formula: String,
}
fn to_js_error(error: String) -> Error {
Error::new(Status::Unknown, error)
}
#[napi]
pub struct UserModel {
model: BaseModel,
}
#[napi]
impl UserModel {
#[napi(constructor)]
pub fn new(name: String, locale: String, timezone: String) -> Result<Self> {
let model = BaseModel::new_empty(&name, &locale, &timezone).map_err(to_js_error)?;
Ok(Self { model })
}
#[napi(factory)]
pub fn from_bytes(bytes: &[u8]) -> Result<UserModel> {
let model = BaseModel::from_bytes(bytes).map_err(to_js_error)?;
Ok(UserModel { model })
}
pub fn undo(&mut self) -> Result<()> {
self.model.undo().map_err(to_js_error)
}
pub fn redo(&mut self) -> Result<()> {
self.model.redo().map_err(to_js_error)
}
#[napi(js_name = "canUndo")]
pub fn can_undo(&self) -> bool {
self.model.can_undo()
}
#[napi(js_name = "canRedo")]
pub fn can_redo(&self) -> bool {
self.model.can_redo()
}
#[napi(js_name = "pauseEvaluation")]
pub fn pause_evaluation(&mut self) {
self.model.pause_evaluation()
}
#[napi(js_name = "resumeEvaluation")]
pub fn resume_evaluation(&mut self) {
self.model.resume_evaluation()
}
pub fn evaluate(&mut self) {
self.model.evaluate();
}
#[napi(js_name = "flushSendQueue")]
pub fn flush_send_queue(&mut self) -> Vec<u8> {
self.model.flush_send_queue()
}
#[napi(js_name = "applyExternalDiffs")]
pub fn apply_external_diffs(&mut self, diffs: &[u8]) -> Result<()> {
self.model.apply_external_diffs(diffs).map_err(to_js_error)
}
#[napi(js_name = "getCellContent")]
pub fn get_cell_content(&self, sheet: u32, row: i32, column: i32) -> Result<String> {
self
.model
.get_cell_content(sheet, row, column)
.map_err(to_js_error)
}
#[napi(js_name = "newSheet")]
pub fn new_sheet(&mut self) -> Result<()> {
self.model.new_sheet().map_err(to_js_error)
}
#[napi(js_name = "deleteSheet")]
pub fn delete_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.delete_sheet(sheet).map_err(to_js_error)
}
#[napi(js_name = "hideSheet")]
pub fn hide_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.hide_sheet(sheet).map_err(to_js_error)
}
#[napi(js_name = "unhideSheet")]
pub fn unhide_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.unhide_sheet(sheet).map_err(to_js_error)
}
#[napi(js_name = "renameSheet")]
pub fn rename_sheet(&mut self, sheet: u32, name: String) -> Result<()> {
self.model.rename_sheet(sheet, &name).map_err(to_js_error)
}
#[napi(js_name = "setSheetColor")]
pub fn set_sheet_color(&mut self, sheet: u32, color: String) -> Result<()> {
self
.model
.set_sheet_color(sheet, &color)
.map_err(to_js_error)
}
#[napi(js_name = "rangeClearAll")]
pub fn range_clear_all(
&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_all(&range).map_err(to_js_error)
}
#[napi(js_name = "rangeClearContents")]
pub fn range_clear_contents(
&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_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)
}
#[napi(js_name = "insertColumn")]
pub fn insert_column(&mut self, sheet: u32, column: i32) -> Result<()> {
self.model.insert_column(sheet, column).map_err(to_js_error)
}
#[napi(js_name = "deleteRow")]
pub fn delete_row(&mut self, sheet: u32, row: i32) -> Result<()> {
self.model.delete_row(sheet, row).map_err(to_js_error)
}
#[napi(js_name = "deleteColumn")]
pub fn delete_column(&mut self, sheet: u32, column: i32) -> Result<()> {
self.model.delete_column(sheet, column).map_err(to_js_error)
}
#[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_rows_height(sheet, row_start, row_end, height)
.map_err(to_js_error)
}
#[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_columns_width(sheet, column_start, column_end, width)
.map_err(to_js_error)
}
#[napi(js_name = "getRowHeight")]
pub fn get_row_height(&mut self, sheet: u32, row: i32) -> Result<f64> {
self.model.get_row_height(sheet, row).map_err(to_js_error)
}
#[napi(js_name = "getColumnWidth")]
pub fn get_column_width(&mut self, sheet: u32, column: i32) -> Result<f64> {
self
.model
.get_column_width(sheet, column)
.map_err(to_js_error)
}
#[napi(js_name = "setUserInput")]
pub fn set_user_input(&mut self, sheet: u32, row: i32, column: i32, input: String) -> Result<()> {
self
.model
.set_user_input(sheet, row, column, &input)
.map_err(to_js_error)
}
#[napi(js_name = "getFormattedCellValue")]
pub fn get_formatted_cell_value(&self, sheet: u32, row: i32, column: i32) -> Result<String> {
self
.model
.get_formatted_cell_value(sheet, row, column)
.map_err(to_js_error)
}
#[napi(js_name = "getFrozenRowsCount")]
pub fn get_frozen_rows_count(&self, sheet: u32) -> Result<i32> {
self.model.get_frozen_rows_count(sheet).map_err(to_js_error)
}
#[napi(js_name = "getFrozenColumnsCount")]
pub fn get_frozen_columns_count(&self, sheet: u32) -> Result<i32> {
self
.model
.get_frozen_columns_count(sheet)
.map_err(to_js_error)
}
#[napi(js_name = "setFrozenRowsCount")]
pub fn set_frozen_rows_count(&mut self, sheet: u32, count: i32) -> Result<()> {
self
.model
.set_frozen_rows_count(sheet, count)
.map_err(to_js_error)
}
#[napi(js_name = "setFrozenColumnsCount")]
pub fn set_frozen_columns_count(&mut self, sheet: u32, count: i32) -> Result<()> {
self
.model
.set_frozen_columns_count(sheet, count)
.map_err(to_js_error)
}
#[napi(js_name = "updateRangeStyle")]
pub fn update_range_style(
&mut self,
env: Env,
range: JsUnknown,
style_path: String,
value: String,
) -> Result<()> {
let range: Area = env
.from_js_value(range)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.update_range_style(&range, &style_path, &value)
.map_err(to_js_error)
}
#[napi(js_name = "getCellStyle")]
pub fn get_cell_style(
&mut self,
env: Env,
sheet: u32,
row: i32,
column: i32,
) -> Result<JsUnknown> {
let style = self
.model
.get_cell_style(sheet, row, column)
.map_err(to_js_error)?;
env
.to_js_value(&style)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "onPasteStyles")]
pub fn on_paste_styles(&mut self, env: Env, styles: JsUnknown) -> Result<()> {
let styles: &Vec<Vec<Style>> = &env
.from_js_value(styles)
.map_err(|e| to_js_error(e.to_string()))?;
self.model.on_paste_styles(styles).map_err(to_js_error)
}
#[napi(js_name = "getCellType")]
pub fn get_cell_type(&self, sheet: u32, row: i32, column: i32) -> Result<i32> {
Ok(
match self
.model
.get_cell_type(sheet, row, column)
.map_err(to_js_error)?
{
CellType::Number => 1,
CellType::Text => 2,
CellType::LogicalValue => 4,
CellType::ErrorValue => 16,
CellType::Array => 64,
CellType::CompoundData => 128,
},
)
}
// I don't _think_ serializing to JsUnknown can't fail
// FIXME: Remove this clippy directive
#[napi(js_name = "getWorksheetsProperties")]
#[allow(clippy::unwrap_used)]
pub fn get_worksheets_properties(&self, env: Env) -> JsUnknown {
env
.to_js_value(&self.model.get_worksheets_properties())
.unwrap()
}
#[napi(js_name = "getSelectedSheet")]
pub fn get_selected_sheet(&self) -> u32 {
self.model.get_selected_sheet()
}
#[napi(js_name = "getSelectedCell")]
pub fn get_selected_cell(&self) -> Vec<i32> {
let (sheet, row, column) = self.model.get_selected_cell();
vec![sheet as i32, row, column]
}
// I don't _think_ serializing to JsUnknown can't fail
// FIXME: Remove this clippy directive
#[napi(js_name = "getSelectedView")]
#[allow(clippy::unwrap_used)]
pub fn get_selected_view(&self, env: Env) -> JsUnknown {
env.to_js_value(&self.model.get_selected_view()).unwrap()
}
#[napi(js_name = "setSelectedSheet")]
pub fn set_selected_sheet(&mut self, sheet: u32) -> Result<()> {
self.model.set_selected_sheet(sheet).map_err(to_js_error)
}
#[napi(js_name = "setSelectedCell")]
pub fn set_selected_cell(&mut self, row: i32, column: i32) -> Result<()> {
self
.model
.set_selected_cell(row, column)
.map_err(to_js_error)
}
#[napi(js_name = "setSelectedRange")]
pub fn set_selected_range(
&mut self,
start_row: i32,
start_column: i32,
end_row: i32,
end_column: i32,
) -> Result<()> {
self
.model
.set_selected_range(start_row, start_column, end_row, end_column)
.map_err(to_js_error)
}
#[napi(js_name = "setTopLeftVisibleCell")]
pub fn set_top_left_visible_cell(&mut self, top_row: i32, top_column: i32) -> Result<()> {
self
.model
.set_top_left_visible_cell(top_row, top_column)
.map_err(to_js_error)
}
#[napi(js_name = "setShowGridLines")]
pub fn set_show_grid_lines(&mut self, sheet: u32, show_grid_lines: bool) -> Result<()> {
self
.model
.set_show_grid_lines(sheet, show_grid_lines)
.map_err(to_js_error)
}
#[napi(js_name = "getShowGridLines")]
pub fn get_show_grid_lines(&mut self, sheet: u32) -> Result<bool> {
self.model.get_show_grid_lines(sheet).map_err(to_js_error)
}
#[napi(js_name = "autoFillRows")]
pub fn auto_fill_rows(&mut self, env: Env, source_area: JsUnknown, to_row: i32) -> Result<()> {
let area: Area = env
.from_js_value(source_area)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.auto_fill_rows(&area, to_row)
.map_err(to_js_error)
}
#[napi(js_name = "autoFillColumns")]
pub fn auto_fill_columns(
&mut self,
env: Env,
source_area: JsUnknown,
to_column: i32,
) -> Result<()> {
let area: Area = env
.from_js_value(source_area)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.auto_fill_columns(&area, to_column)
.map_err(to_js_error)
}
#[napi(js_name = "onArrowRight")]
pub fn on_arrow_right(&mut self) -> Result<()> {
self.model.on_arrow_right().map_err(to_js_error)
}
#[napi(js_name = "onArrowLeft")]
pub fn on_arrow_left(&mut self) -> Result<()> {
self.model.on_arrow_left().map_err(to_js_error)
}
#[napi(js_name = "onArrowUp")]
pub fn on_arrow_up(&mut self) -> Result<()> {
self.model.on_arrow_up().map_err(to_js_error)
}
#[napi(js_name = "onArrowDown")]
pub fn on_arrow_down(&mut self) -> Result<()> {
self.model.on_arrow_down().map_err(to_js_error)
}
#[napi(js_name = "onPageDown")]
pub fn on_page_down(&mut self) -> Result<()> {
self.model.on_page_down().map_err(to_js_error)
}
#[napi(js_name = "onPageUp")]
pub fn on_page_up(&mut self) -> Result<()> {
self.model.on_page_up().map_err(to_js_error)
}
#[napi(js_name = "setWindowWidth")]
pub fn set_window_width(&mut self, window_width: f64) {
self.model.set_window_width(window_width);
}
#[napi(js_name = "setWindowHeight")]
pub fn set_window_height(&mut self, window_height: f64) {
self.model.set_window_height(window_height);
}
#[napi(js_name = "getScrollX")]
pub fn get_scroll_x(&self) -> Result<f64> {
self.model.get_scroll_x().map_err(to_js_error)
}
#[napi(js_name = "getScrollY")]
pub fn get_scroll_y(&self) -> Result<f64> {
self.model.get_scroll_y().map_err(to_js_error)
}
#[napi(js_name = "onExpandSelectedRange")]
pub fn on_expand_selected_range(&mut self, key: String) -> Result<()> {
self
.model
.on_expand_selected_range(&key)
.map_err(to_js_error)
}
#[napi(js_name = "onAreaSelecting")]
pub fn on_area_selecting(&mut self, target_row: i32, target_column: i32) -> Result<()> {
self
.model
.on_area_selecting(target_row, target_column)
.map_err(to_js_error)
}
#[napi(js_name = "setAreaWithBorder")]
pub fn set_area_with_border(
&mut self,
env: Env,
area: JsUnknown,
border_area: JsUnknown,
) -> Result<()> {
let range: Area = env
.from_js_value(area)
.map_err(|e| to_js_error(e.to_string()))?;
let border: BorderArea = env
.from_js_value(border_area)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.set_area_with_border(&range, &border)
.map_err(|e| to_js_error(e.to_string()))?;
Ok(())
}
#[napi(js_name = "toBytes")]
pub fn to_bytes(&self) -> Vec<u8> {
self.model.to_bytes()
}
#[napi(js_name = "getName")]
pub fn get_name(&self) -> String {
self.model.get_name()
}
#[napi(js_name = "setName")]
pub fn set_name(&mut self, name: String) {
self.model.set_name(&name);
}
#[napi(js_name = "copyToClipboard")]
pub fn copy_to_clipboard(&self, env: Env) -> Result<JsUnknown> {
let data = self
.model
.copy_to_clipboard()
.map_err(|e| to_js_error(e.to_string()))?;
env
.to_js_value(&data)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "pasteFromClipboard")]
pub fn paste_from_clipboard(
&mut self,
env: Env,
source_sheet: u32,
source_range: JsUnknown,
clipboard: JsUnknown,
is_cut: bool,
) -> Result<()> {
let source_range: (i32, i32, i32, i32) = env
.from_js_value(source_range)
.map_err(|e| to_js_error(e.to_string()))?;
let clipboard: ClipboardData = env
.from_js_value(clipboard)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.paste_from_clipboard(source_sheet, source_range, &clipboard, is_cut)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "pasteCsvText")]
pub fn paste_csv_string(&mut self, env: Env, area: JsUnknown, csv: String) -> Result<()> {
let range: Area = env
.from_js_value(area)
.map_err(|e| to_js_error(e.to_string()))?;
self
.model
.paste_csv_string(&range, &csv)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "getDefinedNameList")]
pub fn get_defined_name_list(&self, env: Env) -> Result<JsUnknown> {
let data: Vec<DefinedName> = self
.model
.get_defined_name_list()
.iter()
.map(|s| DefinedName {
name: s.0.to_owned(),
scope: s.1,
formula: s.2.to_owned(),
})
.collect();
env
.to_js_value(&data)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "newDefinedName")]
pub fn new_defined_name(
&mut self,
name: String,
scope: Option<u32>,
formula: String,
) -> Result<()> {
self
.model
.new_defined_name(&name, scope, &formula)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "updateDefinedName")]
pub fn update_defined_name(
&mut self,
name: String,
scope: Option<u32>,
new_name: String,
new_scope: Option<u32>,
new_formula: String,
) -> Result<()> {
self
.model
.update_defined_name(&name, scope, &new_name, new_scope, &new_formula)
.map_err(|e| to_js_error(e.to_string()))
}
#[napi(js_name = "deleteDefinedName")]
pub fn delete_definedname(&mut self, name: String, scope: Option<u32>) -> Result<()> {
self
.model
.delete_defined_name(&name, scope)
.map_err(|e| to_js_error(e.to_string()))
}
}

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:: .. toctree::
:maxdepth: 2 :maxdepth: 2
:caption: Contents: :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: A simple example that creates a model, sets a formula, evaluates it and gets the result back:
.. literalinclude:: examples/simple.py .. literalinclude:: examples/simple.py
:language: python :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

@@ -16,7 +16,7 @@ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
"Office/Business :: Financial :: Spreadsheet", "Topic :: Office/Business :: Financial :: Spreadsheet",
] ]
authors = [ authors = [
{ name = "Nicolás Hatcher", email = "nicolas@theuniverse.today" }, { name = "Nicolás Hatcher", email = "nicolas@theuniverse.today" },

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "wasm" name = "wasm"
version = "0.3.0" version = "0.3.2"
authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"] authors = ["Nicolas Hatcher <nicolas@theuniverse.today>"]
description = "IronCalc Web bindings" description = "IronCalc Web bindings"
license = "MIT/Apache-2.0" license = "MIT/Apache-2.0"

View File

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

View File

@@ -174,6 +174,27 @@ impl Model {
self.model.range_clear_contents(&range).map_err(to_js_error) 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")] #[wasm_bindgen(js_name = "insertRow")]
pub fn insert_row(&mut self, sheet: u32, row: i32) -> Result<(), JsError> { pub fn insert_row(&mut self, sheet: u32, row: i32) -> Result<(), JsError> {
self.model.insert_row(sheet, row).map_err(to_js_error) 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) self.model.delete_column(sheet, column).map_err(to_js_error)
} }
#[wasm_bindgen(js_name = "setRowHeight")] #[wasm_bindgen(js_name = "setRowsHeight")]
pub fn set_row_height(&mut self, sheet: u32, row: i32, height: f64) -> Result<(), JsError> { pub fn set_rows_height(
&mut self,
sheet: u32,
row_start: i32,
row_end: i32,
height: f64,
) -> Result<(), JsError> {
self.model self.model
.set_row_height(sheet, row, height) .set_rows_height(sheet, row_start, row_end, height)
.map_err(to_js_error) .map_err(to_js_error)
} }
#[wasm_bindgen(js_name = "setColumnWidth")] #[wasm_bindgen(js_name = "setColumnsWidth")]
pub fn set_column_width(&mut self, sheet: u32, column: i32, width: f64) -> Result<(), JsError> { pub fn set_columns_width(
&mut self,
sheet: u32,
column_start: i32,
column_end: i32,
width: f64,
) -> Result<(), JsError> {
self.model self.model
.set_column_width(sheet, column, width) .set_columns_width(sheet, column_start, column_end, width)
.map_err(to_js_error) .map_err(to_js_error)
} }
@@ -271,6 +304,37 @@ impl Model {
.map_err(to_js_error) .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")] #[wasm_bindgen(js_name = "updateRangeStyle")]
pub fn update_range_style( pub fn update_range_style(
&mut self, &mut self,

View File

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

View File

@@ -6,4 +6,4 @@ lang: en-US
# How to Contribute # 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. 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** ## **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**. 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 # Name Manager
::: warning The **Name Manager** makes working with specific cells or ranges easier by letting you assign custom names and set their scope.
**Note:** This draft page is under construction 🚧
:::
## 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 = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [ addons: [
"@storybook/addon-onboarding",
"@storybook/addon-essentials", "@storybook/addon-essentials",
"@chromatic-com/storybook", "@chromatic-com/storybook",
"@storybook/addon-interactions", "@storybook/addon-interactions",

View File

@@ -14,9 +14,8 @@ npm install
## Local development ## Local development
```bash ```bash
npm run dev npm run storybook
``` ```
## Linter and formatting ## Linter and formatting
@@ -39,20 +38,9 @@ npm run test
Warning: There is only the testing infrastructure in place. 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 ```bash
npm run build 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", "name": "@ironcalc/workbook",
"version": "0.3.1", "version": "0.3.2",
"type": "module", "type": "module",
"main": "./dist/ironcalc.js", "main": "./dist/ironcalc.js",
"module": "./dist/ironcalc.js", "module": "./dist/ironcalc.js",
"source": "./src/index.ts", "source": "./src/index.ts",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"scripts": { "scripts": {
"dev": "vite",
"build": "vite build && tsc", "build": "vite build && tsc",
"check": "biome check ./src", "check": "biome check ./src",
"check-write": "biome check --write ./src", "check-write": "biome check --write ./src",
"test": "vitest run", "test": "vitest run",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006 --no-open",
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
@@ -27,29 +26,28 @@
"react-i18next": "^15.4.0" "react-i18next": "^15.4.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.8.3", "@biomejs/biome": "1.9.4",
"@chromatic-com/storybook": "^3.2.4", "@chromatic-com/storybook": "^3.2.4",
"@storybook/addon-essentials": "^8.6.0-alpha.0", "@storybook/addon-essentials": "^8.6.0",
"@storybook/addon-interactions": "^8.6.0-alpha.0", "@storybook/addon-interactions": "^8.6.0",
"@storybook/addon-onboarding": "^8.6.0-alpha.0", "@storybook/blocks": "^8.6.0",
"@storybook/blocks": "^8.6.0-alpha.0", "@storybook/react": "^8.6.0",
"@storybook/react": "^8.6.0-alpha.0", "@storybook/react-vite": "^8.6.0",
"@storybook/react-vite": "^8.6.0-alpha.0", "@storybook/test": "^8.6.0",
"@storybook/test": "^8.6.0-alpha.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"react": "^18.0.0", "react": "^19.0.0",
"react-dom": "^18.0.0", "react-dom": "^19.0.0",
"storybook": "^8.6.0-alpha.0", "storybook": "^8.6.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5", "vite": "^6.2.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"vitest": "^2.0.5" "vitest": "^3.0.7"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0", "@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0", "react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0" "react-dom": "^18.0.0 || ^19.0.0"
}, },
"files": [ "files": [
"dist" "dist"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import {
styled, styled,
} from "@mui/material"; } from "@mui/material";
import { t } from "i18next"; 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 { useEffect, useState } from "react";
import { theme } from "../../theme"; import { theme } from "../../theme";
import NamedRangeActive from "./NamedRangeActive"; import NamedRangeActive from "./NamedRangeActive";
@@ -79,14 +79,27 @@ function NameManagerDialog(properties: NameManagerDialogProperties) {
</Cross> </Cross>
</StyledDialogTitle> </StyledDialogTitle>
<StyledDialogContent> <StyledDialogContent>
{(definedNameList.length > 0 || editingNameIndex !== -2) && (
<StyledRangesHeader> <StyledRangesHeader>
<StyledBox>{t("name_manager_dialog.name")}</StyledBox> <StyledBox>{t("name_manager_dialog.name")}</StyledBox>
<StyledBox>{t("name_manager_dialog.range")}</StyledBox> <StyledBox>{t("name_manager_dialog.range")}</StyledBox>
<StyledBox>{t("name_manager_dialog.scope")}</StyledBox> <StyledBox>{t("name_manager_dialog.scope")}</StyledBox>
</StyledRangesHeader> </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> <NameListWrapper>
{definedNameList.map((definedName, index) => { {definedNameList.map((definedName, index) => {
const scopeName = definedName.scope const scopeName =
definedName.scope !== undefined
? worksheets[definedName.scope].name ? worksheets[definedName.scope].name
: "[global]"; : "[global]";
if (index === editingNameIndex) { if (index === editingNameIndex) {
@@ -105,7 +118,7 @@ function NameManagerDialog(properties: NameManagerDialogProperties) {
const scope_index = worksheets.findIndex( const scope_index = worksheets.findIndex(
(s) => s.name === newScope, (s) => s.name === newScope,
); );
const scope = scope_index > 0 ? scope_index : undefined; const scope = scope_index >= 0 ? scope_index : undefined;
try { try {
updateDefinedName( updateDefinedName(
definedName.name, definedName.name,
@@ -138,6 +151,7 @@ function NameManagerDialog(properties: NameManagerDialogProperties) {
); );
})} })}
</NameListWrapper> </NameListWrapper>
)}
{editingNameIndex === -1 && ( {editingNameIndex === -1 && (
<NamedRangeActive <NamedRangeActive
worksheets={worksheets} worksheets={worksheets}
@@ -230,6 +244,39 @@ const NameListWrapper = styled(Stack)`
overflow-y: auto; 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)` const StyledBox = styled(Box)`
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,142 @@
import styled from "@emotion/styled";
import { Button, Dialog } from "@mui/material";
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { theme } from "../../theme";
interface SheetDeleteDialogProps {
open: boolean;
onClose: () => void;
onDelete: () => void;
sheetName: string;
}
function SheetDeleteDialog({
open,
onClose,
onDelete,
sheetName,
}: SheetDeleteDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onClose={onClose}>
<DialogWrapper>
<IconWrapper>
<Trash2 />
</IconWrapper>
<Title>{t("sheet_delete.title")}</Title>
<Body>
{t("sheet_delete.message", {
sheetName,
})}
</Body>
<ButtonGroup>
<DeleteButton onClick={onDelete} autoFocus>
{t("sheet_delete.confirm")}
</DeleteButton>
<CancelButton onClick={onClose}>
{t("sheet_delete.cancel")}
</CancelButton>
</ButtonGroup>
</DialogWrapper>
</Dialog>
);
}
const DialogWrapper = styled.div`
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-radius: 8px;
box-shadow: 0px 1px 3px 0px ${theme.palette.common.black}1A;
width: 280px;
max-width: calc(100% - 40px);
z-index: 50;
font-family: "Inter", sans-serif;
`;
const IconWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 36px;
height: 36px;
border-radius: 4px;
background-color: ${theme.palette.error.main}1A;
margin: 12px auto 8px auto;
color: ${theme.palette.error.main};
svg {
width: 16px;
height: 16px;
}
`;
const Title = styled.h2`
margin: 0;
font-size: 14px;
font-weight: 600;
color: ${theme.palette.grey["900"]};
text-align: center;
`;
const Body = styled.p`
margin: 0;
text-align: center;
color: ${theme.palette.grey["900"]};
font-size: 12px;
`;
const ButtonGroup = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
width: 100%;
`;
const StyledButton = styled.button`
cursor: pointer;
color: ${theme.palette.common.white};
background-color: ${theme.palette.primary.main};
padding: 0px 10px;
height: 36px;
border-radius: 4px;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
text-overflow: ellipsis;
transition: background-color 150ms;
text-transform: none;
&:hover {
background-color: ${theme.palette.primary.dark};
}
`;
const DeleteButton = styled(Button)`
background-color: ${theme.palette.error.main};
color: ${theme.palette.common.white};
text-transform: none;
&:hover {
background-color: ${theme.palette.error.dark};
}
`;
const CancelButton = styled(Button)`
background-color: ${theme.palette.grey["200"]};
color: ${theme.palette.grey["700"]};
text-transform: none;
&:hover {
background-color: ${theme.palette.grey["300"]};
}
`;
export default SheetDeleteDialog;

View File

@@ -3,9 +3,10 @@ import type { MenuItemProps } from "@mui/material";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { theme } from "../../theme"; import { theme } from "../../theme";
import ColorPicker from "../colorPicker"; import ColorPicker from "../ColorPicker/ColorPicker";
import { isInReferenceMode } from "../editor/util"; import { isInReferenceMode } from "../Editor/util";
import type { WorkbookState } from "../workbookState"; import type { WorkbookState } from "../workbookState";
import SheetDeleteDialog from "./SheetDeleteDialog";
import SheetRenameDialog from "./SheetRenameDialog"; import SheetRenameDialog from "./SheetRenameDialog";
interface SheetTabProps { interface SheetTabProps {
@@ -25,7 +26,7 @@ function SheetTab(props: SheetTabProps) {
const { name, color, selected, workbookState, onSelected } = props; const { name, color, selected, workbookState, onSelected } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const [colorPickerOpen, setColorPickerOpen] = useState(false); const [colorPickerOpen, setColorPickerOpen] = useState(false);
const colorButton = useRef(null); const colorButton = useRef<HTMLDivElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => { const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@@ -37,20 +38,31 @@ function SheetTab(props: SheetTabProps) {
const handleCloseRenameDialog = () => { const handleCloseRenameDialog = () => {
setRenameDialogOpen(false); setRenameDialogOpen(false);
}; };
const handleOpenRenameDialog = () => { const handleOpenRenameDialog = () => {
setRenameDialogOpen(true); setRenameDialogOpen(true);
}; };
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleOpenDeleteDialog = () => {
setDeleteDialogOpen(true);
};
const handleCloseDeleteDialog = () => {
setDeleteDialogOpen(false);
};
return ( return (
<> <>
<TabWrapper <TabWrapper
$color={color} $color={color}
$selected={selected} $selected={selected}
onClick={(event) => { onClick={(event: React.MouseEvent) => {
onSelected(); onSelected();
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
}} }}
onPointerDown={(event) => { onPointerDown={(event: React.PointerEvent) => {
// If it is in browse mode stop he event // If it is in browse mode stop he event
const cell = workbookState.getEditingCell(); const cell = workbookState.getEditingCell();
if (cell && isInReferenceMode(cell.text, cell.cursorStart)) { if (cell && isInReferenceMode(cell.text, cell.cursorStart)) {
@@ -97,7 +109,7 @@ function SheetTab(props: SheetTabProps) {
<StyledMenuItem <StyledMenuItem
disabled={!props.canDelete} disabled={!props.canDelete}
onClick={() => { onClick={() => {
props.onDeleted(); handleOpenDeleteDialog();
handleClose(); handleClose();
}} }}
> >
@@ -134,6 +146,15 @@ function SheetTab(props: SheetTabProps) {
anchorEl={colorButton} anchorEl={colorButton}
open={colorPickerOpen} open={colorPickerOpen}
/> />
<SheetDeleteDialog
open={deleteDialogOpen}
onClose={handleCloseDeleteDialog}
onDelete={() => {
props.onDeleted();
handleCloseDeleteDialog();
}}
sheetName={name}
/>
</> </>
); );
} }

View File

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

View File

@@ -7,6 +7,8 @@ import type {
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import type {} from "@mui/system"; import type {} from "@mui/system";
import { import {
AArrowDown,
AArrowUp,
AlignCenter, AlignCenter,
AlignLeft, AlignLeft,
AlignRight, AlignRight,
@@ -18,11 +20,13 @@ import {
Grid2X2, Grid2X2,
Grid2x2Check, Grid2x2Check,
Grid2x2X, Grid2x2X,
ImageDown,
Italic, Italic,
PaintBucket, PaintBucket,
PaintRoller, PaintRoller,
Percent, Percent,
Redo2, Redo2,
RemoveFormatting,
Strikethrough, Strikethrough,
Tags, Tags,
Type, Type,
@@ -35,19 +39,19 @@ import {
ArrowMiddleFromLine, ArrowMiddleFromLine,
DecimalPlacesDecreaseIcon, DecimalPlacesDecreaseIcon,
DecimalPlacesIncreaseIcon, DecimalPlacesIncreaseIcon,
} from "../icons"; } from "../../icons";
import { theme } from "../theme"; import { theme } from "../../theme";
import NameManagerDialog from "./NameManagerDialog"; import BorderPicker from "../BorderPicker/BorderPicker";
import type { NameManagerProperties } from "./NameManagerDialog/NameManagerDialog"; import ColorPicker from "../ColorPicker/ColorPicker";
import BorderPicker from "./borderPicker"; import FormatMenu from "../FormatMenu/FormatMenu";
import ColorPicker from "./colorPicker";
import { TOOLBAR_HEIGHT } from "./constants";
import FormatMenu from "./formatMenu";
import { import {
NumberFormats, NumberFormats,
decreaseDecimalPlaces, decreaseDecimalPlaces,
increaseDecimalPlaces, increaseDecimalPlaces,
} from "./formatUtil"; } from "../FormatMenu/formatUtil";
import NameManagerDialog from "../NameManagerDialog";
import type { NameManagerProperties } from "../NameManagerDialog/NameManagerDialog";
import { TOOLBAR_HEIGHT } from "../constants";
type ToolbarProperties = { type ToolbarProperties = {
canUndo: boolean; canUndo: boolean;
@@ -65,6 +69,9 @@ type ToolbarProperties = {
onFillColorPicked: (hex: string) => void; onFillColorPicked: (hex: string) => void;
onNumberFormatPicked: (numberFmt: string) => void; onNumberFormatPicked: (numberFmt: string) => void;
onBorderChanged: (border: BorderOptions) => void; onBorderChanged: (border: BorderOptions) => void;
onClearFormatting: () => void;
onIncreaseFontSize: (delta: number) => void;
onDownloadPNG: () => void;
fillColor: string; fillColor: string;
fontColor: string; fontColor: string;
bold: boolean; bold: boolean;
@@ -246,6 +253,28 @@ function Toolbar(properties: ToolbarProperties) {
> >
<Type /> <Type />
</StyledButton> </StyledButton>
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
onClick={() => {
properties.onIncreaseFontSize(1);
}}
title={t("toolbar.increase_font_size")}
>
<AArrowUp />
</StyledButton>
<StyledButton
type="button"
$pressed={false}
disabled={!canEdit}
onClick={() => {
properties.onIncreaseFontSize(-1);
}}
title={t("toolbar.decrease_font_size")}
>
<AArrowDown />
</StyledButton>
<StyledButton <StyledButton
type="button" type="button"
$pressed={false} $pressed={false}
@@ -360,6 +389,30 @@ function Toolbar(properties: ToolbarProperties) {
<Tags /> <Tags />
</StyledButton> </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 <ColorPicker
color={properties.fontColor} color={properties.fontColor}
onChange={(color): void => { onChange={(color): void => {

View File

@@ -6,30 +6,35 @@ import type {
} from "@ironcalc/wasm"; } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import SheetTabBar from "./SheetTabBar/SheetTabBar"; import FormulaBar from "../FormulaBar/FormulaBar";
import SheetTabBar from "../SheetTabBar";
import Toolbar from "../Toolbar/Toolbar";
import Worksheet from "../Worksheet/Worksheet";
import { import {
COLUMN_WIDTH_SCALE, COLUMN_WIDTH_SCALE,
LAST_COLUMN, LAST_COLUMN,
ROW_HEIGH_SCALE, ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants"; } from "../WorksheetCanvas/constants";
import type WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import { devicePixelRatio } from "../WorksheetCanvas/worksheetCanvas";
import { import {
CLIPBOARD_ID_SESSION_STORAGE_KEY, CLIPBOARD_ID_SESSION_STORAGE_KEY,
getNewClipboardId, getNewClipboardId,
} from "./clipboard"; } from "../clipboard";
import FormulaBar from "./formulabar";
import Toolbar from "./toolbar";
import useKeyboardNavigation from "./useKeyboardNavigation";
import { import {
type NavigationKey, type NavigationKey,
getCellAddress, getCellAddress,
getFullRangeToString, getFullRangeToString,
} from "./util"; } from "../util";
import type { WorkbookState } from "./workbookState"; import type { WorkbookState } from "../workbookState";
import Worksheet from "./worksheet"; import useKeyboardNavigation from "./useKeyboardNavigation";
const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
const { model, workbookState } = props; const { model, workbookState } = props;
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement | null>(null);
const worksheetRef = useRef<{
getCanvas: () => WorksheetCanvas | null;
}>(null);
// Calling `setRedrawId((id) => id + 1);` forces a redraw // Calling `setRedrawId((id) => id + 1);` forces a redraw
// This is needed because `model` or `workbookState` can change without React being aware of it // This is needed because `model` or `workbookState` can change without React being aware of it
@@ -119,6 +124,10 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
updateRangeStyle("num_fmt", numberFmt); updateRangeStyle("num_fmt", numberFmt);
}; };
const onIncreaseFontSize = (delta: number) => {
updateRangeStyle("font.size_delta", `${delta}`);
};
const onCopyStyles = () => { const onCopyStyles = () => {
const { const {
sheet, sheet,
@@ -222,17 +231,17 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
}, },
onBold: () => { onBold: () => {
const { sheet, row, column } = model.getSelectedView(); 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); onToggleBold(!value);
}, },
onItalic: () => { onItalic: () => {
const { sheet, row, column } = model.getSelectedView(); 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); onToggleItalic(!value);
}, },
onUnderline: () => { onUnderline: () => {
const { sheet, row, column } = model.getSelectedView(); 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); onToggleUnderline(!value);
}, },
onNavigationToEdge: (direction: NavigationKey): void => { onNavigationToEdge: (direction: NavigationKey): void => {
@@ -350,7 +359,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
ref={rootRef} ref={rootRef}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
tabIndex={0} tabIndex={0}
onClick={(event) => { onClick={(event: React.MouseEvent) => {
if (!workbookState.getEditingCell()) { if (!workbookState.getEditingCell()) {
focusWorkbook(); focusWorkbook();
} else { } else {
@@ -527,6 +536,76 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
onTextColorPicked={onTextColorPicked} onTextColorPicked={onTextColorPicked}
onFillColorPicked={onFillColorPicked} onFillColorPicked={onFillColorPicked}
onNumberFormatPicked={onNumberFormatPicked} 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 => { onBorderChanged={(border: BorderOptions): void => {
const { const {
sheet, sheet,
@@ -619,6 +698,7 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
refresh={(): void => { refresh={(): void => {
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}} }}
ref={worksheetRef}
/> />
<SheetTabBar <SheetTabBar

View File

@@ -1,5 +1,5 @@
import { type KeyboardEvent, type RefObject, useCallback } from "react"; import { type KeyboardEvent, type RefObject, useCallback } from "react";
import { type NavigationKey, isEditingKey, isNavigationKey } from "./util"; import { type NavigationKey, isEditingKey, isNavigationKey } from "../util";
export enum Border { export enum Border {
Top = "top", Top = "top",
@@ -32,7 +32,7 @@ interface Options {
onNextSheet: () => void; onNextSheet: () => void;
onPreviousSheet: () => void; onPreviousSheet: () => void;
onEscape: () => void; onEscape: () => void;
root: RefObject<HTMLDivElement>; root: RefObject<HTMLDivElement | null>;
} }
// # IronCalc Keyboard accessibility: // # 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 { Model } from "@ironcalc/wasm";
import { type PointerEvent, type RefObject, useCallback, useRef } from "react"; import { type PointerEvent, type RefObject, useCallback, useRef } from "react";
import type WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas"; import { isInReferenceMode } from "../Editor/util";
import type WorksheetCanvas from "../WorksheetCanvas/worksheetCanvas";
import { import {
headerColumnWidth, headerColumnWidth,
headerRowHeight, headerRowHeight,
} from "./WorksheetCanvas/worksheetCanvas"; } from "../WorksheetCanvas/worksheetCanvas";
import { isInReferenceMode } from "./editor/util"; import type { Cell } from "../types";
import type { Cell } from "./types"; import { rangeToStr } from "../util";
import { rangeToStr } from "./util"; import type { WorkbookState } from "../workbookState";
import type { WorkbookState } from "./workbookState";
interface PointerSettings { interface PointerSettings {
canvasElement: RefObject<HTMLCanvasElement>; canvasElement: RefObject<HTMLCanvasElement | null>;
worksheetCanvas: RefObject<WorksheetCanvas | null>; worksheetCanvas: RefObject<WorksheetCanvas | null>;
worksheetElement: RefObject<HTMLDivElement>; worksheetElement: RefObject<HTMLDivElement | null>;
onCellSelected: (cell: Cell, event: React.MouseEvent) => void; 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; onAreaSelecting: (cell: Cell) => void;
onAreaSelected: () => void; onAreaSelected: () => void;
onExtendToCell: (cell: Cell) => void; onExtendToCell: (cell: Cell) => void;
@@ -116,6 +119,11 @@ const usePointer = (options: PointerSettings): PointerEvents => {
const onPointerDown = useCallback( const onPointerDown = useCallback(
(event: PointerEvent) => { (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 x = event.clientX;
let y = event.clientY; let y = event.clientY;
const { const {
@@ -125,6 +133,9 @@ const usePointer = (options: PointerSettings): PointerEvents => {
worksheetElement, worksheetElement,
worksheetCanvas, worksheetCanvas,
workbookState, workbookState,
onRowSelected,
onColumnSelected,
onAllSheetSelected,
} = options; } = options;
const worksheet = worksheetCanvas.current; const worksheet = worksheetCanvas.current;
const canvas = canvasElement.current; const canvas = canvasElement.current;
@@ -143,7 +154,10 @@ const usePointer = (options: PointerSettings): PointerEvents => {
y < headerRowHeight || y < headerRowHeight ||
y > canvasRect.height y > canvasRect.height
) { ) {
if ( if (x < headerColumnWidth && y < headerRowHeight) {
// Click on the top left corner
onAllSheetSelected();
} else if (
x > 0 && x > 0 &&
x < headerColumnWidth && x < headerColumnWidth &&
y > headerRowHeight && y > headerRowHeight &&
@@ -152,8 +166,18 @@ const usePointer = (options: PointerSettings): PointerEvents => {
// Click on a row number // Click on a row number
const cell = worksheet.getCellByCoordinates(headerColumnWidth, y); const cell = worksheet.getCellByCoordinates(headerColumnWidth, y);
if (cell) { if (cell) {
// TODO onRowSelected(cell.row, event.shiftKey);
// Row selected }
} 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; return;
@@ -240,7 +264,6 @@ const usePointer = (options: PointerSettings): PointerEvents => {
onPointerMove, onPointerMove,
onPointerUp, onPointerUp,
onPointerHandleDown, onPointerHandleDown,
// onContextMenu,
}; };
}; };

View File

@@ -1,6 +1,6 @@
import type { Model } from "@ironcalc/wasm"; import type { Model } from "@ironcalc/wasm";
import { columnNameFromNumber } from "@ironcalc/wasm"; import { columnNameFromNumber } from "@ironcalc/wasm";
import { getColor } from "../editor/util"; import { getColor } from "../Editor/util";
import type { Cell } from "../types"; import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState"; import type { WorkbookState } from "../workbookState";
import { import {
@@ -37,6 +37,7 @@ export interface CanvasSettings {
}; };
onColumnWidthChanges: (sheet: number, column: number, width: number) => void; onColumnWidthChanges: (sheet: number, column: number, width: number) => void;
onRowHeightChanges: (sheet: number, row: number, height: number) => void; onRowHeightChanges: (sheet: number, row: number, height: number) => void;
refresh: () => void;
} }
export const fonts = { export const fonts = {
@@ -106,6 +107,8 @@ export default class WorksheetCanvas {
onRowHeightChanges: (sheet: number, row: number, height: number) => void; onRowHeightChanges: (sheet: number, row: number, height: number) => void;
refresh: () => void;
constructor(options: CanvasSettings) { constructor(options: CanvasSettings) {
this.model = options.model; this.model = options.model;
this.sheetWidth = 0; this.sheetWidth = 0;
@@ -116,6 +119,7 @@ export default class WorksheetCanvas {
this.ctx = this.setContext(); this.ctx = this.setContext();
this.workbookState = options.workbookState; this.workbookState = options.workbookState;
this.editor = options.elements.editor; this.editor = options.elements.editor;
this.refresh = options.refresh;
this.cellOutline = options.elements.cellOutline; this.cellOutline = options.elements.cellOutline;
this.cellOutlineHandle = options.elements.cellOutlineHandle; this.cellOutlineHandle = options.elements.cellOutlineHandle;
@@ -349,7 +353,7 @@ export default class WorksheetCanvas {
? gridColor ? gridColor
: backgroundColor; : backgroundColor;
const fontSize = 13; const fontSize = style.font?.sz || 13;
let font = `${fontSize}px ${defaultCellFontFamily}`; let font = `${fontSize}px ${defaultCellFontFamily}`;
let textColor = defaultTextColor; let textColor = defaultTextColor;
if (style.font) { if (style.font) {
@@ -577,23 +581,54 @@ export default class WorksheetCanvas {
let resizeHandleUp = (event: MouseEvent): void => { let resizeHandleUp = (event: MouseEvent): void => {
div.style.opacity = "0"; div.style.opacity = "0";
this.columnGuide.style.display = "none"; this.columnGuide.style.display = "none";
document.removeEventListener("mousemove", resizeHandleMove); document.removeEventListener("pointermove", resizeHandleMove);
document.removeEventListener("mouseup", resizeHandleUp); document.removeEventListener("pointerup", resizeHandleUp);
const newColumnWidth = columnWidth + event.pageX - initPageX; const newColumnWidth = columnWidth + event.pageX - initPageX;
if (newColumnWidth !== columnWidth) {
this.onColumnWidthChanges( this.onColumnWidthChanges(
this.model.getSelectedSheet(), this.model.getSelectedSheet(),
column, column,
newColumnWidth, newColumnWidth,
); );
}
}; };
resizeHandleUp = resizeHandleUp.bind(this); resizeHandleUp = resizeHandleUp.bind(this);
div.addEventListener("mousedown", (event) => { div.addEventListener("pointerdown", (event) => {
div.style.opacity = "1"; div.style.opacity = "1";
this.columnGuide.style.display = "block"; this.columnGuide.style.display = "block";
this.columnGuide.style.left = `${headerColumnWidth + x}px`; this.columnGuide.style.left = `${headerColumnWidth + x}px`;
initPageX = event.pageX; initPageX = event.pageX;
document.addEventListener("mousemove", resizeHandleMove); document.addEventListener("pointermove", resizeHandleMove);
document.addEventListener("mouseup", resizeHandleUp); 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;
// This is a bit of a HACK. We should use the actual font size and weather is bold or not
const fontSize = 13;
this.ctx.font = `${fontSize}px ${defaultCellFontFamily}`;
for (const row of rows) {
const fullText = this.model.getFormattedCellValue(sheet, row, column);
if (fullText === "") {
continue;
}
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 +650,52 @@ export default class WorksheetCanvas {
let resizeHandleUp = (event: MouseEvent): void => { let resizeHandleUp = (event: MouseEvent): void => {
div.style.opacity = "0"; div.style.opacity = "0";
this.rowGuide.style.display = "none"; this.rowGuide.style.display = "none";
document.removeEventListener("mousemove", resizeHandleMove); document.removeEventListener("pointermove", resizeHandleMove);
document.removeEventListener("mouseup", resizeHandleUp); document.removeEventListener("pointerup", resizeHandleUp);
const newRowHeight = rowHeight + event.pageY - initPageY - 1; const newRowHeight = rowHeight + event.pageY - initPageY;
if (newRowHeight !== rowHeight) {
this.onRowHeightChanges(sheet, row, newRowHeight); this.onRowHeightChanges(sheet, row, newRowHeight);
}
}; };
resizeHandleUp = resizeHandleUp.bind(this); resizeHandleUp = resizeHandleUp.bind(this);
/* istanbul ignore next */ /* istanbul ignore next */
div.addEventListener("mousedown", (event) => { div.addEventListener("pointerdown", (event) => {
event.stopPropagation();
div.style.opacity = "1"; div.style.opacity = "1";
this.rowGuide.style.display = "block"; this.rowGuide.style.display = "block";
this.rowGuide.style.top = `${y}px`; this.rowGuide.style.top = `${y}px`;
initPageY = event.pageY; initPageY = event.pageY;
document.addEventListener("mousemove", resizeHandleMove); document.addEventListener("pointermove", resizeHandleMove);
document.addEventListener("mouseup", resizeHandleUp); 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;
const lineHeight = 22;
// This is a bit of a HACK. We should use the actual font size and weather is bold or not
const fontSize = 13;
this.ctx.font = `${fontSize}px ${defaultCellFontFamily}`;
for (const column of columns) {
const fullText = this.model.getFormattedCellValue(sheet, row, column);
if (fullText === "") {
continue;
}
const lines = fullText.split("\n");
const lineCount = lines.length;
// This si 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 +1421,7 @@ export default class WorksheetCanvas {
let y = headerRowHeight + 0.5; let y = headerRowHeight + 0.5;
for (let row = 1; row <= frozenRows; row += 1) { for (let row = 1; row <= frozenRows; row += 1) {
const rowHeight = this.getRowHeight(selectedSheet, row); const rowHeight = this.getRowHeight(selectedSheet, row);
x = headerColumnWidth; x = headerColumnWidth + 0.5;
for (let column = 1; column <= frozenColumns; column += 1) { for (let column = 1; column <= frozenColumns; column += 1) {
const columnWidth = this.getColumnWidth(selectedSheet, column); const columnWidth = this.getColumnWidth(selectedSheet, column);
this.renderCell(row, column, x, y, columnWidth, rowHeight); this.renderCell(row, column, x, y, columnWidth, rowHeight);
@@ -1363,7 +1430,7 @@ export default class WorksheetCanvas {
y += rowHeight; y += rowHeight;
} }
if (frozenRows === 0 && frozenColumns !== 0) { if (frozenRows === 0 && frozenColumns !== 0) {
x = headerColumnWidth; x = headerColumnWidth + 0.5;
for (let column = 1; column <= frozenColumns; column += 1) { for (let column = 1; column <= frozenColumns; column += 1) {
x += this.getColumnWidth(selectedSheet, column); x += this.getColumnWidth(selectedSheet, column);
} }
@@ -1397,7 +1464,7 @@ export default class WorksheetCanvas {
const frozenX = x; const frozenX = x;
const frozenY = y; const frozenY = y;
// Draw frozen rows (top-right pane) // Draw frozen rows (top-right pane)
y = headerRowHeight; y = headerRowHeight + 0.5;
for (let row = 1; row <= frozenRows; row += 1) { for (let row = 1; row <= frozenRows; row += 1) {
x = frozenX; x = frozenX;
const rowHeight = this.getRowHeight(selectedSheet, row); const rowHeight = this.getRowHeight(selectedSheet, row);

View File

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

View File

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

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", "redo": "Redo",
"undo": "Undo", "undo": "Undo",
"copy_styles": "Copy styles", "copy_styles": "Copy styles",
"clear_formatting": "Clear formatting",
"euro": "Format as Euro", "euro": "Format as Euro",
"percentage": "Format as Percentage", "percentage": "Format as Percentage",
"bold": "Bold", "bold": "Bold",
@@ -15,6 +16,8 @@
"format_number": "Format number", "format_number": "Format number",
"font_color": "Font color", "font_color": "Font color",
"fill_color": "Fill color", "fill_color": "Fill color",
"increase_font_size": "Increase font size",
"decrease_font_size": "Decrease font size",
"decimal_places_increase": "Increase decimal places", "decimal_places_increase": "Increase decimal places",
"decimal_places_decrease": "Decrease decimal places", "decimal_places_decrease": "Decrease decimal places",
"show_hide_grid_lines": "Show/hide grid lines", "show_hide_grid_lines": "Show/hide grid lines",
@@ -22,6 +25,7 @@
"vertical_align_bottom": "Align bottom", "vertical_align_bottom": "Align bottom",
"vertical_align_middle": " Align middle", "vertical_align_middle": " Align middle",
"vertical_align_top": "Align top", "vertical_align_top": "Align top",
"selected_png": "Export Selected area as PNG",
"format_menu": { "format_menu": {
"auto": "Auto", "auto": "Auto",
"number": "Number", "number": "Number",
@@ -68,6 +72,12 @@
"title": "Rename Sheet", "title": "Rename Sheet",
"close": "Close dialog" "close": "Close dialog"
}, },
"sheet_delete": {
"title": "Are you sure?",
"message": "The sheet '{{sheetName}}' will be deleted.",
"confirm": "Yes, delete sheet",
"cancel": "Cancel"
},
"formula_input": { "formula_input": {
"update": "Update", "update": "Update",
"label": "Formula", "label": "Formula",
@@ -79,6 +89,8 @@
}, },
"name_manager_dialog": { "name_manager_dialog": {
"title": "Named Ranges", "title": "Named Ranges",
"empty_message1": "No named ranges added yet.",
"empty_message2": "Click on 'Add new' to add one.",
"name": "Name", "name": "Name",
"range": "Scope", "range": "Scope",
"scope": "Range", "scope": "Range",
@@ -91,5 +103,23 @@
"edit": "Edit Range", "edit": "Edit Range",
"apply": "Apply changes", "apply": "Apply changes",
"discard": "Discard 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 reverse_proxy /api/* 127.0.0.1:8000
# everything else is the frontend # 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": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@ironcalc/workbook": "^0.3.1", "@ironcalc/workbook": "file:../../IronCalc/",
"@mui/material": "^6.3.1", "@mui/material": "^6.4",
"lucide": "^0.469.0", "lucide-react": "^0.473.0",
"lucide-react": "^0.469.0", "qrcode.react": "^4.2.0",
"react": "^18.3.1", "react": "^19.0.0",
"react-dom": "^18.3.1" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.8.3", "@biomejs/biome": "1.9.4",
"@types/react": "^18.3.18", "@types/react": "^19.0.0",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.5", "vite": "^6.0.5",

View File

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

View File

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

View File

@@ -110,14 +110,12 @@ export function FileMenu(props: {
aria-labelledby="modal-modal-title" aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description" aria-describedby="modal-modal-description"
> >
<>
<UploadFileDialog <UploadFileDialog
onClose={() => { onClose={() => {
setImportMenuOpen(false); setImportMenuOpen(false);
}} }}
onModelUpload={props.onModelUpload} onModelUpload={props.onModelUpload}
/> />
</>
</Modal> </Modal>
<Modal <Modal
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
@@ -125,13 +123,11 @@ export function FileMenu(props: {
aria-labelledby="delete-dialog-title" aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description" aria-describedby="delete-dialog-description"
> >
<>
<DeleteWorkbookDialog <DeleteWorkbookDialog
onClose={() => setDeleteDialogOpen(false)} onClose={() => setDeleteDialogOpen(false)}
onConfirm={props.onDelete} onConfirm={props.onDelete}
workbookName={selectedUuid ? models[selectedUuid] : ""} workbookName={selectedUuid ? models[selectedUuid] : ""}
/> />
</>
</Modal> </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 <DialogWrapper
open={true} open={true}
tabIndex={0} tabIndex={0}
role="dialog"
onClose={handleClose} onClose={handleClose}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.code === "Escape") { if (event.code === "Escape") {

View File

@@ -9,7 +9,7 @@ use ironcalc::compare::{test_file, test_load_and_saving};
use ironcalc::export::save_to_xlsx; use ironcalc::export::save_to_xlsx;
use ironcalc::import::{load_from_icalc, load_from_xlsx, load_from_xlsx_bytes}; use ironcalc::import::{load_from_icalc, load_from_xlsx, load_from_xlsx_bytes};
use ironcalc_base::types::{HorizontalAlignment, VerticalAlignment}; use ironcalc_base::types::{HorizontalAlignment, VerticalAlignment};
use ironcalc_base::Model; use ironcalc_base::{Model, UserModel};
// This is a functional test. // This is a functional test.
// We check that the output of example.xlsx is what we expect. // 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(); 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();
}