Compare commits

...

63 Commits

Author SHA1 Message Date
Nicolás Hatcher
24dd63b261 FIX: Correct tag number (oops) 2024-11-15 01:07:02 +01:00
Nicolás Hatcher
861700cb45 UPDATE: Adds CHANGELOG 2024-11-15 00:59:13 +01:00
Mehdi Armachi
98dc557a01 Adds navigation labels for sheet management 2024-11-11 10:36:54 +01:00
Nicolás Hatcher
2c2228c2c2 FIX: [App]: Borders done right 2024-10-31 22:43:43 +01:00
Nicolás Hatcher
494a315cbd FIX: Do geometry right 2024-10-31 22:43:43 +01:00
Nicolás Hatcher
0c69889832 FIX: Column/Row width/height UI issues 2024-10-31 22:43:43 +01:00
Nicolás Hatcher
04d8c658ab UPDATE: Adds cut/paste 2024-10-31 22:43:43 +01:00
Nicolás Hatcher
dad4755b16 FIX: Fixes from Dani's design 2024-10-26 11:04:52 +02:00
Nicolás Hatcher
75d8a5282e FIX: Slightly better behaviour for increase/decrease decimal places
The general solution must be done in Rust and it is a bit more complex.
2024-10-26 11:04:52 +02:00
Nicolás Hatcher
f78027247b FIX: Make biome happy 2024-10-26 11:04:52 +02:00
Nicolás Hatcher
ee6a41c4f4 FIX: Nicer loading image 2024-10-26 11:04:52 +02:00
Nicolás Hatcher
b7336f70d6 FIX[App]: Font-size of menu is 12px 2024-10-26 11:04:52 +02:00
Nicolás Hatcher
dae37f14ba FIX[App]: Over scroll issues 2024-10-26 11:04:52 +02:00
Nicolás Hatcher
7ffbfac432 FIX[WebApp]: fixes in formula bar
* fx is not clickable
* Removed chevron
* Show slecting/ed area in address
2024-10-24 21:54:34 +02:00
Nicolás Hatcher
f9ea4fd757 FIX[WebApp]: Only show the active ranges in the correct sheet
Fixes #104
2024-10-24 21:54:34 +02:00
Nicolás Hatcher
7446932519 FIX[WebApp]: Keep the area extended as selected
Fixes #110
2024-10-24 21:54:34 +02:00
Nicolás Hatcher
d55845e69f FIX[WebApp]: PreventDefault when clicking on the Format Editor
Fixes #112
2024-10-24 21:54:34 +02:00
Nicolás Hatcher
9e5b959ccc FIX[WebApp]: Pass the name along to the serve
Fixes #111
2024-10-24 21:54:34 +02:00
Nicolás Hatcher
ffa93309e2 FIX: Renaming a sheet with the same name doesn't do anything
Fixes #103
2024-10-24 21:54:34 +02:00
Nicolás Hatcher
79216b286b FIX: Caret color is IronCalc 2024-10-24 21:54:34 +02:00
Nicolás Hatcher
411d4a3780 FIX: Make biome happy 2024-10-24 21:54:34 +02:00
Nicolás Hatcher
3a7aa15347 FIX: Mark code as ununsed for now 2024-10-24 21:54:34 +02:00
Nicolás Hatcher
090e852054 FIX: Make selected sheet bold 2024-10-24 21:54:34 +02:00
Nicolás Hatcher
3e54ad5b3c FIX: Rename sheet dialog with correct default name
Fixes #103
2024-10-24 21:54:34 +02:00
Nicolás Hatcher
7b12c2682e FIX: Headers height show be the same as the default row height 2024-10-24 21:54:34 +02:00
Nicolás Hatcher
80273a88ec FIX: When creating a new sheet, select it
Fixes #100
2024-10-24 21:54:34 +02:00
Nicolás Hatcher
3d951c5c50 FIX: Several UI fixes from Dani
* Toast has Inter font-family
* Share button has Inter font family
* More accurate menu list to design
* Removes unused navigation
* Adds link to IronCalc
* Removes line=black :O
2024-10-23 22:42:22 +02:00
Nicolás Hatcher
cd54389e91 UPDATE: Implement copy/paste in the UI 2024-10-23 21:43:18 +02:00
Nicolás Hatcher
843d8beb02 FIX: Once again more
apparently I don't have anything better to do :D
2024-10-15 19:29:21 +02:00
Nicolás Hatcher
09ac29785d FIX[wasm]: Fixes failing test 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
2b530423c8 FIX[base]: Adds test for names and row heigh 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
51c41900d7 FIX: Fix broken tests 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
730a815729 FIX[Editor]: More simplifications and fixes 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
9805d0c518 FIX: Set the color of the refe range to be the next from the active ranges 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
10a9d36f3d FIX: Make biome happy 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
480640dc98 UPDATE[WebApp]: we can now delete models on the localStorage 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
3058a63e4f FIX: Correct default for vertical align 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
8275d73b64 FIX: Set default row height to 22
This matches the line height. So far a magic number
2024-10-15 19:29:21 +02:00
Nicolás Hatcher
072abb2240 FIX: Vertical Align by default is bottom 2024-10-15 19:29:21 +02:00
Nicolás Hatcher
9a46e5ccc7 FIX: More fixes to the cell editor
* Font family is Inter, font size 13, line-width 22
* Correct vertical align for multiline text
* Entering multiline text sets the height of the row (!)
2024-10-15 19:29:21 +02:00
Nicolás Hatcher
585e594d8d FIX: Diverse fixes to the editor
* Editor now expands as you write
* You can switch between the formula bar and cell editor
* While editing in the formula bar you see the results in the editor
* Give Mateusz more credit
2024-10-15 19:29:21 +02:00
Nicolás Hatcher
248ef66e7c FIX: Make biome happy 2024-10-11 21:08:16 +02:00
Nicolás Hatcher
15da2e5785 FIX: Close the sheet list menu when a sheet is selected 2024-10-11 21:08:16 +02:00
Nicolás Hatcher
39174add1f FIX: number format menu closes when selected 2024-10-11 21:08:16 +02:00
Nicolás Hatcher
e412f5fc22 FIX: Delete the selected area correctly
Previously it was deleting one extra row and column
2024-10-11 21:08:16 +02:00
Nicolás Hatcher
42c1a39131 FIX: Cell editor correct behaviour 2024-10-11 21:08:16 +02:00
Nicolás Hatcher
f26cdd3a4b FIX: Sets the patternFill to solid when changing the background color 2024-10-11 21:08:16 +02:00
Nicolás Hatcher
4016eb5944 FIX: Better support for mobile phones 2024-10-11 21:08:16 +02:00
Nicolás Hatcher
58dfdd329e FIX: Fix broken build 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
4a290aec7c FIX: Forgotten file :S 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
3966dbc790 FIX: Correct font-size in navigation bar 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
abd4ce4ea5 FIX: Let’s move the outline handle to left and top 1px 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
02da1eb388 FIX: Make default cells 25% larger 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
1131234531 FIX: Slightly better widths in the row headers
I'm afraid this nees to be completely redone
2024-10-08 23:10:34 +02:00
Nicolás Hatcher
b495397b5f FIX: Proper imports 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
8c0a566995 FIX: Set grid color to grey-300 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
dd62dd2dc6 FIX: Set format menu font-size to 12px 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
79b7b9b817 FIX: Correct paddings in formula bar 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
06ae1a1d6d FIX: Fix tooltips on buttons
* Strike through,
* Hide grid lines
* all vertical/horizontal align buttons
2024-10-08 23:10:34 +02:00
Nicolás Hatcher
6390739fd4 FIX: Correct height of toolbar (48) and formula bar (40) 2024-10-08 23:10:34 +02:00
Nicolás Hatcher
e41741cf77 FIX: Change border color between toolbox and formula bar to grey-300 2024-10-08 23:10:34 +02:00
Nicolás Hatcher Andrés
48719b6416 UPDATE: Adds cell and formula editing (#92)
* UPDATE: Adds cell and formula editing

* FIX: Do not loose focus when clicking on the formula we are editing

* FIX: Minimal implementation of browse mode

* FIX: Initial browse mode within sheets

* UPDATE: Webapp

Minimal Web Application
2024-10-08 19:44:27 +02:00
Nicolás Hatcher Andrés
53d3d5144c UPDATE: point documentation to app instead of playground (#93) 2024-09-28 19:23:32 +02:00
68 changed files with 4124 additions and 480 deletions

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
# CHANGELOG
## [Unreleased]
## [0.2.0] - 2024-11-06 (The HN release)
### Added
- Rust crate ironcalc_base
- Rust crate ironcalc
- Minimal Python bindings (only Linux)
- JavaScript bindings
- React WebApp
[0.2.0]: https://github.com/IronCalc/ironcalc/releases/tag/v0.2.0

112
Cargo.lock generated
View File

@@ -19,6 +19,15 @@ dependencies = [
"cpufeatures", "cpufeatures",
] ]
[[package]]
name = "aho-corasick"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81ce3d38065e618af2d7b77e10c5ad9a069859b4be3c2250f674af3840d9c8a5"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.3" version = "1.1.3"
@@ -76,6 +85,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -242,6 +257,40 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "csv"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "csv-core"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70"
dependencies = [
"memchr",
]
[[package]]
name = "csv-sniffer"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b8e952164bb270a505d6cb6136624174c34cfb9abd16e0011f5e53058317f39"
dependencies = [
"bitflags",
"csv",
"csv-core",
"memchr",
"regex 0.2.11",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.3.11" version = "0.3.11"
@@ -375,10 +424,12 @@ dependencies = [
"bitcode", "bitcode",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"csv",
"csv-sniffer",
"js-sys", "js-sys",
"once_cell", "once_cell",
"rand", "rand",
"regex", "regex 1.10.4",
"ryu", "ryu",
"serde", "serde",
"serde_json", "serde_json",
@@ -417,6 +468,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.153" version = "0.2.153"
@@ -480,7 +537,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41"
dependencies = [ dependencies = [
"regex", "regex 1.10.4",
] ]
[[package]] [[package]]
@@ -689,16 +746,29 @@ dependencies = [
"getrandom", "getrandom",
] ]
[[package]]
name = "regex"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9329abc99e39129fcceabd24cf5d85b4671ef7c29c50e972bc5afe32438ec384"
dependencies = [
"aho-corasick 0.6.10",
"memchr",
"regex-syntax 0.5.6",
"thread_local",
"utf8-ranges",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.10.4" version = "1.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick 1.1.3",
"memchr", "memchr",
"regex-automata", "regex-automata",
"regex-syntax", "regex-syntax 0.8.3",
] ]
[[package]] [[package]]
@@ -707,9 +777,18 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick 1.1.3",
"memchr", "memchr",
"regex-syntax", "regex-syntax 0.8.3",
]
[[package]]
name = "regex-syntax"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d707a4fa2637f2dca2ef9fd02225ec7661fe01a53623c1e6515b6916511f7a7"
dependencies = [
"ucd-util",
] ]
[[package]] [[package]]
@@ -849,6 +928,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6b53e329000edc2b34dbe8545fd20e55a333362d0a321909685a19bd28c3f1b"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.34" version = "0.3.34"
@@ -874,6 +962,12 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "ucd-util"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd2fc5d32b590614af8b0a20d837f32eca055edd0bbead59a9cfe80858be003"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.12" version = "1.0.12"
@@ -886,6 +980,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce"
[[package]]
name = "utf8-ranges"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.8.0" version = "1.8.0"

View File

@@ -123,7 +123,7 @@ See https://github.com/ironcalc
An early preview of the technology running entirely in your browser: An early preview of the technology running entirely in your browser:
https://playground.ironcalc.com https://app.ironcalc.com
# Collaborators needed!. Call to action # Collaborators needed!. Call to action

View File

@@ -18,6 +18,8 @@ chrono-tz = "0.9"
regex = "1.0" regex = "1.0"
once_cell = "1.16.0" once_cell = "1.16.0"
bitcode = "0.6.0" bitcode = "0.6.0"
csv = "1.3.0"
csv-sniffer = "0.1"
[dev-dependencies] [dev-dependencies]
serde_json = "1.0" serde_json = "1.0"

View File

@@ -2,8 +2,8 @@
/// COLUMN_WIDTH and ROW_HEIGHT are pixel values /// COLUMN_WIDTH and ROW_HEIGHT are pixel values
/// A column width of Excel value `w` will result in `w * COLUMN_WIDTH_FACTOR` pixels /// A column width of Excel value `w` will result in `w * COLUMN_WIDTH_FACTOR` pixels
/// Note that these constants are inlined /// Note that these constants are inlined
pub(crate) const DEFAULT_COLUMN_WIDTH: f64 = 100.0; pub(crate) const DEFAULT_COLUMN_WIDTH: f64 = 125.0;
pub(crate) const DEFAULT_ROW_HEIGHT: f64 = 21.0; pub(crate) const DEFAULT_ROW_HEIGHT: f64 = 28.0;
pub(crate) const COLUMN_WIDTH_FACTOR: f64 = 12.0; pub(crate) const COLUMN_WIDTH_FACTOR: f64 = 12.0;
pub(crate) const ROW_HEIGHT_FACTOR: f64 = 2.0; pub(crate) const ROW_HEIGHT_FACTOR: f64 = 2.0;
pub(crate) const DEFAULT_WINDOW_HEIGH: i64 = 600; pub(crate) const DEFAULT_WINDOW_HEIGH: i64 = 600;

View File

@@ -58,4 +58,5 @@ pub mod mock_time;
pub use model::get_milliseconds_since_epoch; pub use model::get_milliseconds_since_epoch;
pub use model::Model; pub use model::Model;
pub use user_model::BorderArea; pub use user_model::BorderArea;
pub use user_model::ClipboardData;
pub use user_model::UserModel; pub use user_model::UserModel;

View File

@@ -1,6 +1,6 @@
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
use crate::constants::LAST_COLUMN; use crate::constants::{DEFAULT_ROW_HEIGHT, LAST_COLUMN};
use crate::model::Model; use crate::model::Model;
use crate::test::util::new_empty_model; use crate::test::util::new_empty_model;
use crate::types::Col; use crate::types::Col;
@@ -87,7 +87,8 @@ fn test_insert_rows_styles() {
let mut model = new_empty_model(); let mut model = new_empty_model();
assert!( assert!(
(21.0 - model.workbook.worksheet(0).unwrap().row_height(10).unwrap()).abs() < f64::EPSILON (DEFAULT_ROW_HEIGHT - model.workbook.worksheet(0).unwrap().row_height(10).unwrap()).abs()
< f64::EPSILON
); );
// sets height 42 in row 10 // sets height 42 in row 10
model model
@@ -106,7 +107,8 @@ fn test_insert_rows_styles() {
// Row 10 has the default height // Row 10 has the default height
assert!( assert!(
(21.0 - model.workbook.worksheet(0).unwrap().row_height(10).unwrap()).abs() < f64::EPSILON (DEFAULT_ROW_HEIGHT - model.workbook.worksheet(0).unwrap().row_height(10).unwrap()).abs()
< f64::EPSILON
); );
// Row 10 is now row 15 // Row 10 is now row 15
@@ -120,7 +122,8 @@ fn test_delete_rows_styles() {
let mut model = new_empty_model(); let mut model = new_empty_model();
assert!( assert!(
(21.0 - model.workbook.worksheet(0).unwrap().row_height(10).unwrap()).abs() < f64::EPSILON (DEFAULT_ROW_HEIGHT - model.workbook.worksheet(0).unwrap().row_height(10).unwrap()).abs()
< f64::EPSILON
); );
// sets height 42 in row 10 // sets height 42 in row 10
model model
@@ -139,7 +142,8 @@ fn test_delete_rows_styles() {
// Row 10 has the default height // Row 10 has the default height
assert!( assert!(
(21.0 - model.workbook.worksheet(0).unwrap().row_height(10).unwrap()).abs() < f64::EPSILON (DEFAULT_ROW_HEIGHT - model.workbook.worksheet(0).unwrap().row_height(10).unwrap()).abs()
< f64::EPSILON
); );
// Row 10 is now row 5 // Row 10 is now row 5

View File

@@ -11,6 +11,7 @@ mod test_keyboard_navigation;
mod test_on_area_selection; 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_rename_sheet; mod test_rename_sheet;
mod test_row_column; mod test_row_column;
mod test_styles; mod test_styles;

View File

@@ -5,13 +5,13 @@ use crate::{constants::DEFAULT_COLUMN_WIDTH, UserModel};
#[test] #[test]
fn add_undo_redo() { fn add_undo_redo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.new_sheet(); model.new_sheet().unwrap();
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_column_width(1, 5, 5.0 * DEFAULT_COLUMN_WIDTH)
.unwrap(); .unwrap();
model.new_sheet(); model.new_sheet().unwrap();
model.set_user_input(2, 1, 1, "=Sheet2!B1").unwrap(); model.set_user_input(2, 1, 1, "=Sheet2!B1").unwrap();
model.undo().unwrap(); model.undo().unwrap();
@@ -59,7 +59,7 @@ fn set_sheet_color() {
#[test] #[test]
fn new_sheet_propagates() { fn new_sheet_propagates() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.new_sheet(); model.new_sheet().unwrap();
let send_queue = model.flush_send_queue(); let send_queue = model.flush_send_queue();
@@ -72,7 +72,7 @@ fn new_sheet_propagates() {
#[test] #[test]
fn delete_sheet_propagates() { fn delete_sheet_propagates() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.new_sheet(); model.new_sheet().unwrap();
model.delete_sheet(0).unwrap(); model.delete_sheet(0).unwrap();
let send_queue = model.flush_send_queue(); let send_queue = model.flush_send_queue();
@@ -87,10 +87,18 @@ fn delete_sheet_propagates() {
fn delete_last_sheet() { fn delete_last_sheet() {
// Deleting the last sheet, selects the previous // Deleting the last sheet, selects the previous
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.new_sheet(); model.new_sheet().unwrap();
model.new_sheet(); model.new_sheet().unwrap();
model.set_selected_sheet(2).unwrap(); model.set_selected_sheet(2).unwrap();
model.delete_sheet(2).unwrap(); model.delete_sheet(2).unwrap();
assert_eq!(model.get_selected_sheet(), 1); assert_eq!(model.get_selected_sheet(), 1);
} }
#[test]
fn new_sheet_selects_it() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
assert_eq!(model.get_selected_sheet(), 0);
model.new_sheet().unwrap();
assert_eq!(model.get_selected_sheet(), 1);
}

View File

@@ -51,6 +51,84 @@ fn borders_all() {
} }
} }
// let's check the borders around
{
let row = 4;
for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: None,
top: None,
bottom: Some(border_item.clone()),
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
let row = 9;
for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: None,
top: Some(border_item.clone()),
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
}
{
let column = 5;
for row in 5..9 {
let style = model.get_cell_style(0, row, column).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);
}
let column = 9;
for row in 5..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: Some(border_item.clone()),
right: None,
top: None,
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
}
// Lets remove all of them: // Lets remove all of them:
let border_area: BorderArea = serde_json::from_str( let border_area: BorderArea = serde_json::from_str(
r##"{ r##"{
@@ -63,8 +141,8 @@ fn borders_all() {
) )
.unwrap(); .unwrap();
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 4..10 {
for column in 6..9 { for column in 5..10 {
let style = model.get_cell_style(0, row, column).unwrap(); let style = model.get_cell_style(0, row, column).unwrap();
let expected_border = Border { let expected_border = Border {
diagonal_up: false, diagonal_up: false,
@@ -229,6 +307,84 @@ fn borders_outer() {
}; };
assert_eq!(style.border, expected_border); assert_eq!(style.border, expected_border);
} }
// let's check the borders around
{
let row = 4;
for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: None,
top: None,
bottom: Some(border_item.clone()),
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
let row = 9;
for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: None,
top: Some(border_item.clone()),
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
}
{
let column = 5;
for row in 5..9 {
let style = model.get_cell_style(0, row, column).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);
}
let column = 9;
for row in 5..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: Some(border_item.clone()),
right: None,
top: None,
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
}
} }
#[test] #[test]
@@ -275,6 +431,72 @@ fn borders_top() {
assert_eq!(style.border, expected_border); assert_eq!(style.border, expected_border);
} }
} }
// let's check the borders around
{
let row = 4;
for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let border_item = BorderItem {
style: BorderStyle::Thin,
color: Some("#FF5566".to_string()),
};
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: None,
top: None,
bottom: Some(border_item.clone()),
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
let row = 9;
for column in 6..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: None,
top: None,
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
}
{
let column = 5;
for row in 5..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: None,
top: None,
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
let column = 9;
for row in 5..9 {
let style = model.get_cell_style(0, row, column).unwrap();
let expected_border = Border {
diagonal_up: false,
diagonal_down: false,
left: None,
right: None,
top: None,
bottom: None,
diagonal: None,
};
assert_eq!(style.border, expected_border);
}
}
} }
#[test] #[test]

View File

@@ -138,7 +138,7 @@ fn queue_undo_redo_multiple() {
#[test] #[test]
fn new_sheet() { fn new_sheet() {
let mut model1 = UserModel::from_model(new_empty_model()); let mut model1 = UserModel::from_model(new_empty_model());
model1.new_sheet(); model1.new_sheet().unwrap();
model1.set_user_input(0, 1, 1, "42").unwrap(); model1.set_user_input(0, 1, 1, "42").unwrap();
model1.set_user_input(1, 1, 1, "=Sheet1!A1*2").unwrap(); model1.set_user_input(1, 1, 1, "=Sheet1!A1*2").unwrap();

View File

@@ -129,3 +129,12 @@ fn delete_remove_cell() {
let (sheet, row, column) = (0, 1, 1); let (sheet, row, column) = (0, 1, 1);
model.set_user_input(sheet, row, column, "100$").unwrap(); model.set_user_input(sheet, row, column, "100$").unwrap();
} }
#[test]
fn get_and_set_name() {
let mut model = UserModel::new_empty("MyWorkbook123", "en", "UTC").unwrap();
assert_eq!(model.get_name(), "MyWorkbook123");
model.set_name("Another name");
assert_eq!(model.get_name(), "Another name");
}

View File

@@ -7,7 +7,7 @@ use crate::UserModel;
fn basic_tests() { fn basic_tests() {
let model = new_empty_model(); let model = new_empty_model();
let mut model = UserModel::from_model(model); let mut model = UserModel::from_model(model);
model.new_sheet(); model.new_sheet().unwrap();
// default sheet has show_grid_lines = true // default sheet has show_grid_lines = true
assert_eq!(model.get_show_grid_lines(0), Ok(true)); assert_eq!(model.get_show_grid_lines(0), Ok(true));

View File

@@ -0,0 +1,164 @@
use crate::{expressions::types::Area, UserModel};
#[test]
fn csv_paste() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 7, 7, "=SUM(B4:D7)").unwrap();
assert_eq!(model.get_formatted_cell_value(0, 7, 7), Ok("0".to_string()));
// paste some numbers in B4:C7
let csv = "1,2,3\n4,5,6";
let area = Area {
sheet: 0,
row: 4,
column: 2,
width: 1,
height: 1,
};
model.set_selected_cell(4, 2).unwrap();
model.paste_csv_string(&area, csv).unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 7, 7),
Ok("21".to_string())
);
}
#[test]
fn tsv_crlf_paste() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 7, 7, "=SUM(B4:D7)").unwrap();
assert_eq!(model.get_formatted_cell_value(0, 7, 7), Ok("0".to_string()));
// paste some numbers in B4:C7
let csv = "1\t2\t3\r\n4\t5\t6";
let area = Area {
sheet: 0,
row: 4,
column: 2,
width: 1,
height: 1,
};
model.set_selected_cell(4, 2).unwrap();
model.paste_csv_string(&area, csv).unwrap();
assert_eq!(
model.get_formatted_cell_value(0, 7, 7),
Ok("21".to_string())
);
}
#[test]
fn cut_paste() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_user_input(0, 1, 2, "=A1*3+1").unwrap();
// set A1 bold
let range = Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
};
model.update_range_style(&range, "font.b", "true").unwrap();
model
.set_user_input(0, 2, 1, "A season of faith, \"perfection\"")
.unwrap();
// Select A1:B2 and copy
model.set_selected_range(1, 1, 2, 2).unwrap();
let copy = model.copy_to_clipboard().unwrap();
model.set_selected_cell(4, 4).unwrap();
// paste in cell D4 (4, 4)
model
.paste_from_clipboard((1, 1, 2, 2), &copy.data, true)
.unwrap();
assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string()));
assert_eq!(model.get_cell_content(0, 4, 5), Ok("=D4*3+1".to_string()));
assert_eq!(
model.get_formatted_cell_value(0, 4, 5),
Ok("127".to_string())
);
// cell D4 must be bold
let style_d4 = model.get_cell_style(0, 4, 4).unwrap();
assert!(style_d4.font.b);
// range A1:B2 must be empty
assert_eq!(model.get_cell_content(0, 1, 1), Ok("".to_string()));
assert_eq!(model.get_cell_content(0, 1, 2), Ok("".to_string()));
assert_eq!(model.get_cell_content(0, 2, 1), Ok("".to_string()));
assert_eq!(model.get_cell_content(0, 2, 2), Ok("".to_string()));
}
#[test]
fn copy_paste_internal() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.set_user_input(0, 1, 1, "42").unwrap();
model.set_user_input(0, 1, 2, "=A1*3+1").unwrap();
// set A1 bold
let range = Area {
sheet: 0,
row: 1,
column: 1,
width: 1,
height: 1,
};
model.update_range_style(&range, "font.b", "true").unwrap();
model
.set_user_input(0, 2, 1, "A season of faith, \"perfection\"")
.unwrap();
// Select A1:B2 and copy
model.set_selected_range(1, 1, 2, 2).unwrap();
let copy = model.copy_to_clipboard().unwrap();
assert_eq!(
copy.csv,
"42,127\n\"A season of faith, \"\"perfection\"\"\",\n"
);
assert_eq!(copy.range, (1, 1, 2, 2));
model.set_selected_cell(4, 4).unwrap();
// paste in cell D4 (4, 4)
model
.paste_from_clipboard((1, 1, 2, 2), &copy.data, false)
.unwrap();
assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string()));
assert_eq!(model.get_cell_content(0, 4, 5), Ok("=D4*3+1".to_string()));
assert_eq!(
model.get_formatted_cell_value(0, 4, 5),
Ok("127".to_string())
);
// cell D4 must be bold
let style_d4 = model.get_cell_style(0, 4, 4).unwrap();
assert!(style_d4.font.b);
model.undo().unwrap();
assert_eq!(model.get_cell_content(0, 4, 4), Ok("".to_string()));
assert_eq!(model.get_cell_content(0, 4, 5), Ok("".to_string()));
// cell D4 must not be bold
let style_d4 = model.get_cell_style(0, 4, 4).unwrap();
assert!(!style_d4.font.b);
model.redo().unwrap();
assert_eq!(model.get_cell_content(0, 4, 4), Ok("42".to_string()));
assert_eq!(model.get_cell_content(0, 4, 5), Ok("=D4*3+1".to_string()));
assert_eq!(
model.get_formatted_cell_value(0, 4, 5),
Ok("127".to_string())
);
// cell D4 must be bold
let style_d4 = model.get_cell_style(0, 4, 4).unwrap();
assert!(style_d4.font.b);
}

View File

@@ -9,6 +9,13 @@ fn basic_rename() {
assert_eq!(model.get_worksheets_properties()[0].name, "NewSheet"); assert_eq!(model.get_worksheets_properties()[0].name, "NewSheet");
} }
#[test]
fn rename_with_same_name() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();
model.rename_sheet(0, "Sheet1").unwrap();
assert_eq!(model.get_worksheets_properties()[0].name, "Sheet1");
}
#[test] #[test]
fn undo_redo() { fn undo_redo() {
let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); let mut model = UserModel::new_empty("model", "en", "UTC").unwrap();

View File

@@ -154,3 +154,21 @@ fn simple_delete_row_no_style() {
assert_eq!(model.get_formatted_cell_value(0, 15, 6), Ok("".to_string())); assert_eq!(model.get_formatted_cell_value(0, 15, 6), Ok("".to_string()));
} }
#[test]
fn row_heigh_increases_automatically() {
let mut model = UserModel::new_empty("Workbook1", "en", "UTC").unwrap();
assert_eq!(model.get_row_height(0, 1), Ok(DEFAULT_ROW_HEIGHT));
// Entering a single line does not change the height
model
.set_user_input(0, 1, 1, "My home in Canada had horses")
.unwrap();
assert_eq!(model.get_row_height(0, 1), Ok(DEFAULT_ROW_HEIGHT));
// entering a two liner does:
model
.set_user_input(0, 1, 1, "My home in Canada had horses\nAnd monkeys!")
.unwrap();
assert_eq!(model.get_row_height(0, 1), Ok(2.0 * DEFAULT_ROW_HEIGHT));
}

View File

@@ -145,6 +145,7 @@ fn basic_fill() {
let style = model.get_cell_style(0, 1, 1).unwrap(); let style = model.get_cell_style(0, 1, 1).unwrap();
assert_eq!(style.fill.bg_color, None); assert_eq!(style.fill.bg_color, None);
assert_eq!(style.fill.fg_color, None); assert_eq!(style.fill.fg_color, None);
assert_eq!(&style.fill.pattern_type, "none");
// bg_color // bg_color
model model
@@ -156,6 +157,7 @@ fn basic_fill() {
let style = model.get_cell_style(0, 1, 1).unwrap(); let style = model.get_cell_style(0, 1, 1).unwrap();
assert_eq!(style.fill.bg_color, Some("#F2F2F2".to_owned())); assert_eq!(style.fill.bg_color, Some("#F2F2F2".to_owned()));
assert_eq!(style.fill.fg_color, Some("#F3F4F5".to_owned())); assert_eq!(style.fill.fg_color, Some("#F3F4F5".to_owned()));
assert_eq!(&style.fill.pattern_type, "solid");
let send_queue = model.flush_send_queue(); let send_queue = model.flush_send_queue();

View File

@@ -65,13 +65,13 @@ fn set_the_range_does_not_set_the_cell() {
fn add_new_sheet_and_back() { fn add_new_sheet_and_back() {
let model = new_empty_model(); let model = new_empty_model();
let mut model = UserModel::from_model(model); let mut model = UserModel::from_model(model);
model.new_sheet(); model.new_sheet().unwrap();
assert_eq!(model.get_selected_sheet(), 0); assert_eq!(model.get_selected_sheet(), 1);
model.set_selected_cell(5, 4).unwrap(); model.set_selected_cell(5, 4).unwrap();
model.set_selected_sheet(1).unwrap();
assert_eq!(model.get_selected_cell(), (1, 1, 1));
model.set_selected_sheet(0).unwrap(); model.set_selected_sheet(0).unwrap();
assert_eq!(model.get_selected_cell(), (0, 5, 4)); assert_eq!(model.get_selected_cell(), (0, 1, 1));
model.set_selected_sheet(1).unwrap();
assert_eq!(model.get_selected_cell(), (1, 5, 4));
} }
#[test] #[test]

View File

@@ -7,17 +7,20 @@ use crate::{
pub enum Units { pub enum Units {
Number { Number {
#[allow(dead_code)]
group_separator: bool, group_separator: bool,
precision: i32, precision: i32,
num_fmt: String, num_fmt: String,
}, },
Currency { Currency {
#[allow(dead_code)]
group_separator: bool, group_separator: bool,
precision: i32, precision: i32,
num_fmt: String, num_fmt: String,
currency: String, currency: String,
}, },
Percentage { Percentage {
#[allow(dead_code)]
group_separator: bool, group_separator: bool,
precision: i32, precision: i32,
num_fmt: String, num_fmt: String,

View File

@@ -1,13 +1,15 @@
#![deny(missing_docs)] #![deny(missing_docs)]
use std::{collections::HashMap, fmt::Debug}; use std::{collections::HashMap, fmt::Debug, io::Cursor};
use csv::{ReaderBuilder, WriterBuilder};
use csv_sniffer::Sniffer;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
constants, constants::{self, DEFAULT_ROW_HEIGHT, LAST_COLUMN, LAST_ROW},
expressions::{ expressions::{
types::Area, types::{Area, CellReferenceIndex},
utils::{is_valid_column_number, is_valid_row}, utils::{is_valid_column_number, is_valid_row},
}, },
model::Model, model::Model,
@@ -21,8 +23,25 @@ use crate::{
use crate::user_model::history::{ use crate::user_model::history::{
ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData,
}; };
/// Data for the clipboard
pub type ClipboardData = HashMap<i32, HashMap<i32, ClipboardCell>>;
pub type ClipboardTuple = (i32, i32, i32, i32);
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct ClipboardCell {
text: String,
style: Style,
}
#[derive(Serialize, Deserialize)]
pub struct Clipboard {
pub(crate) csv: String,
pub(crate) data: ClipboardData,
pub(crate) range: (i32, i32, i32, i32),
}
#[derive(Serialize, Deserialize, PartialEq)]
pub enum BorderType { pub enum BorderType {
All, All,
Inner, Inner,
@@ -205,6 +224,16 @@ impl UserModel {
self.model.to_bytes() self.model.to_bytes()
} }
/// Returns the workbook name
pub fn get_name(&self) -> String {
self.model.workbook.name.clone()
}
/// Sets the name of a workbook
pub fn set_name(&mut self, name: &str) {
self.model.workbook.name = name.to_string();
}
/// Undoes last change if any, places the change in the redo list and evaluates the model if needed /// Undoes last change if any, places the change in the redo list and evaluates the model if needed
/// ///
/// See also: /// See also:
@@ -335,13 +364,27 @@ impl UserModel {
self.evaluate_if_not_paused(); self.evaluate_if_not_paused();
let diff_list = vec![Diff::SetCellValue { let mut diff_list = vec![Diff::SetCellValue {
sheet, sheet,
row, row,
column, column,
new_value: value.to_string(), new_value: value.to_string(),
old_value: Box::new(old_value), old_value: Box::new(old_value),
}]; }];
let line_count = value.split("\n").count();
let row_height = self.model.get_row_height(sheet, row)?;
let cell_height = (line_count as f64) * DEFAULT_ROW_HEIGHT;
if cell_height > row_height {
diff_list.push(Diff::SetRowHeight {
sheet,
row,
new_value: cell_height,
old_value: row_height,
});
self.model.set_row_height(sheet, row, cell_height)?;
}
self.push_diff_list(diff_list); self.push_diff_list(diff_list);
Ok(()) Ok(())
} }
@@ -381,9 +424,11 @@ impl UserModel {
/// ///
/// See also: /// See also:
/// * [Model::new_sheet] /// * [Model::new_sheet]
pub fn new_sheet(&mut self) { pub fn new_sheet(&mut self) -> Result<(), String> {
let (name, index) = self.model.new_sheet(); let (name, index) = self.model.new_sheet();
self.set_selected_sheet(index)?;
self.push_diff_list(vec![Diff::NewSheet { index, name }]); self.push_diff_list(vec![Diff::NewSheet { index, name }]);
Ok(())
} }
/// Deletes sheet by index /// Deletes sheet by index
@@ -412,6 +457,9 @@ impl UserModel {
/// * [Model::rename_sheet_by_index] /// * [Model::rename_sheet_by_index]
pub fn rename_sheet(&mut self, sheet: u32, new_name: &str) -> Result<(), String> { pub fn rename_sheet(&mut self, sheet: u32, new_name: &str) -> Result<(), String> {
let old_value = self.model.workbook.worksheet(sheet)?.name.clone(); let old_value = self.model.workbook.worksheet(sheet)?.name.clone();
if old_value == new_name {
return Ok(());
}
self.model.rename_sheet_by_index(sheet, new_name)?; self.model.rename_sheet_by_index(sheet, new_name)?;
self.push_diff_list(vec![Diff::RenameSheet { self.push_diff_list(vec![Diff::RenameSheet {
index: sheet, index: sheet,
@@ -748,7 +796,6 @@ impl UserModel {
range: &Area, range: &Area,
border_area: &BorderArea, border_area: &BorderArea,
) -> Result<(), String> { ) -> Result<(), String> {
// FIXME: We need to set the border also in neighbouring cells.
let sheet = range.sheet; let sheet = range.sheet;
let mut diff_list = Vec::new(); let mut diff_list = Vec::new();
let last_row = range.row + range.height - 1; let last_row = range.row + range.height - 1;
@@ -758,12 +805,6 @@ impl UserModel {
let old_value = self.model.get_style_for_cell(sheet, row, column)?; let old_value = self.model.get_style_for_cell(sheet, row, column)?;
let mut style = old_value.clone(); let mut style = old_value.clone();
// First remove all existing borders
style.border.top = None;
style.border.right = None;
style.border.bottom = None;
style.border.left = None;
match border_area.r#type { match border_area.r#type {
BorderType::All => { BorderType::All => {
style.border.top = Some(border_area.item.clone()); style.border.top = Some(border_area.item.clone());
@@ -820,7 +861,10 @@ impl UserModel {
} }
} }
BorderType::None => { BorderType::None => {
// noop, we already removed all the borders style.border.top = None;
style.border.right = None;
style.border.bottom = None;
style.border.left = None;
} }
} }
@@ -834,6 +878,122 @@ impl UserModel {
}); });
} }
} }
// bottom of the cells above the first
if range.row > 1
&& [
BorderType::Top,
BorderType::All,
BorderType::None,
BorderType::Outer,
]
.contains(&border_area.r#type)
{
let row = range.row - 1;
for column in range.column..=last_column {
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
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(old_value),
new_value: Box::new(style),
});
}
}
// Cells to the right
if last_column < LAST_COLUMN
&& [
BorderType::Right,
BorderType::All,
BorderType::None,
BorderType::Outer,
]
.contains(&border_area.r#type)
{
let column = last_column + 1;
for row in range.row..=last_row {
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
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(old_value),
new_value: Box::new(style),
});
}
}
// Cells bellow
if last_row < LAST_ROW
&& [
BorderType::Bottom,
BorderType::All,
BorderType::None,
BorderType::Outer,
]
.contains(&border_area.r#type)
{
let row = last_row + 1;
for column in range.column..=last_column {
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
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(old_value),
new_value: Box::new(style),
});
}
}
// Cells to the left
if range.column > 1
&& [
BorderType::Left,
BorderType::All,
BorderType::None,
BorderType::Outer,
]
.contains(&border_area.r#type)
{
let column = range.column - 1;
for row in range.row..=last_row {
let old_value = self.model.get_style_for_cell(sheet, row, column)?;
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(old_value),
new_value: Box::new(style),
});
}
}
self.push_diff_list(diff_list); self.push_diff_list(diff_list);
Ok(()) Ok(())
@@ -872,9 +1032,11 @@ impl UserModel {
} }
"fill.bg_color" => { "fill.bg_color" => {
style.fill.bg_color = color(value)?; style.fill.bg_color = color(value)?;
style.fill.pattern_type = "solid".to_string();
} }
"fill.fg_color" => { "fill.fg_color" => {
style.fill.fg_color = color(value)?; style.fill.fg_color = color(value)?;
style.fill.pattern_type = "solid".to_string();
} }
"num_fmt" => { "num_fmt" => {
value.clone_into(&mut style.num_fmt); value.clone_into(&mut style.num_fmt);
@@ -950,7 +1112,7 @@ impl UserModel {
/// See also: /// See also:
/// * [Model::get_style_for_cell] /// * [Model::get_style_for_cell]
#[inline] #[inline]
pub fn get_cell_style(&mut self, sheet: u32, row: i32, column: i32) -> Result<Style, String> { pub fn get_cell_style(&self, sheet: u32, row: i32, column: i32) -> Result<Style, String> {
self.model.get_style_for_cell(sheet, row, column) self.model.get_style_for_cell(sheet, row, column)
} }
@@ -1183,6 +1345,215 @@ impl UserModel {
Ok(self.model.workbook.worksheet(sheet)?.show_grid_lines) Ok(self.model.workbook.worksheet(sheet)?.show_grid_lines)
} }
/// Returns a copy of the selected area
pub fn copy_to_clipboard(&self) -> Result<Clipboard, String> {
let selected_area = self.get_selected_view();
let sheet = selected_area.sheet;
let mut wtr = WriterBuilder::new().from_writer(vec![]);
let mut data = HashMap::new();
let [row_start, column_start, row_end, column_end] = selected_area.range;
for row in row_start..=row_end {
let mut data_row = HashMap::new();
let mut text_row = Vec::new();
for column in column_start..=column_end {
let text = self.get_formatted_cell_value(sheet, row, column)?;
let content = self.get_cell_content(sheet, row, column)?;
let style = self.get_cell_style(sheet, row, column)?;
data_row.insert(
column,
ClipboardCell {
text: content,
style,
},
);
text_row.push(text);
}
wtr.write_record(text_row).unwrap();
data.insert(row, data_row);
}
let csv = String::from_utf8(wtr.into_inner().unwrap()).unwrap();
Ok(Clipboard {
csv,
data,
range: (row_start, column_start, row_end, column_end),
})
}
/// Paste text that we copied
pub fn paste_from_clipboard(
&mut self,
source_range: ClipboardTuple,
clipboard: &ClipboardData,
is_cut: bool,
) -> Result<(), String> {
let mut diff_list = Vec::new();
let view = self.get_selected_view();
let (source_first_row, source_first_column, source_last_row, source_last_column) =
source_range;
let sheet = view.sheet;
let [selected_row, selected_column, _, _] = view.range;
let mut max_row = selected_row;
let mut max_column = selected_column;
let area = &Area {
sheet,
row: source_first_row,
column: source_first_column,
width: source_last_column - source_first_column + 1,
height: source_last_row - source_first_row + 1,
};
for (source_row, data_row) in clipboard {
let delta_row = source_row - source_first_row;
let target_row = selected_row + delta_row;
max_row = max_row.max(target_row);
for (source_column, value) in data_row {
let delta_column = source_column - source_first_column;
let target_column = selected_column + delta_column;
max_column = max_column.max(target_column);
// We are copying the value in
// (source_row, source_column) to (target_row , target_column)
// References in formulas are displaced
// remain in the copied area
let source = &CellReferenceIndex {
sheet,
column: *source_column,
row: *source_row,
};
let target = &CellReferenceIndex {
sheet,
column: target_column,
row: target_row,
};
let new_value = if is_cut {
self.model
.move_cell_value_to_area(&value.text, source, target, area)?
} else {
self.model
.extend_copied_value(&value.text, source, target)?
};
let old_value = self
.model
.workbook
.worksheet(sheet)?
.cell(target_row, target_column)
.cloned();
let old_style = self
.model
.get_style_for_cell(sheet, target_row, target_column)?;
self.model
.set_user_input(sheet, target_row, target_column, new_value.clone())?;
self.model
.set_cell_style(sheet, target_row, target_column, &value.style)?;
diff_list.push(Diff::SetCellValue {
sheet,
row: target_row,
column: target_column,
new_value,
old_value: Box::new(old_value),
});
diff_list.push(Diff::SetCellStyle {
sheet,
row: target_row,
column: target_column,
old_value: Box::new(old_style),
new_value: Box::new(value.style.clone()),
});
}
}
if is_cut {
for row in source_first_row..=source_last_row {
for column in source_first_column..=source_last_column {
let old_value = self
.model
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned();
diff_list.push(Diff::CellClearContents {
sheet,
row,
column,
old_value: Box::new(old_value),
});
self.model.cell_clear_contents(sheet, row, column)?;
}
}
}
self.push_diff_list(diff_list);
// select the pasted area
self.set_selected_range(selected_row, selected_column, max_row, max_column)?;
self.evaluate_if_not_paused();
Ok(())
}
/// Paste a csv-string into the model
pub fn paste_csv_string(&mut self, area: &Area, csv: &str) -> Result<(), String> {
let mut diff_list = Vec::new();
let sheet = area.sheet;
let mut row = area.row;
let mut column = area.column;
// Create a sniffer with default settings
let mut sniffer = Sniffer::new();
let mut csv_reader = Cursor::new(csv);
// Sniff the CSV metadata
let metadata = sniffer
.sniff_reader(&mut csv_reader)
.map_err(|_| "Failed")?;
// Reset the cursor to the beginning after sniffing
csv_reader.set_position(0);
let mut reader = ReaderBuilder::new()
.delimiter(metadata.dialect.delimiter)
.has_headers(false)
.from_reader(csv_reader);
for record in reader.records() {
match record {
Ok(r) => {
column = area.column;
for value in &r {
let old_value = self
.model
.workbook
.worksheet(sheet)?
.cell(row, column)
.cloned();
// let old_style = self.model.get_style_for_cell(sheet, row, column)?;
self.model
.set_user_input(sheet, row, column, value.to_string())?;
diff_list.push(Diff::SetCellValue {
sheet,
row,
column,
new_value: value.to_string(),
old_value: Box::new(old_value),
});
column += 1;
}
}
Err(_) => {
// skip
continue;
}
};
row += 1;
}
self.push_diff_list(diff_list);
// select the pasted area
self.set_selected_range(area.row, area.column, row, column)?;
self.evaluate_if_not_paused();
Ok(())
}
// **** Private methods ****** // // **** Private methods ****** //
fn push_diff_list(&mut self, diff_list: DiffList) { fn push_diff_list(&mut self, diff_list: DiffList) {

View File

@@ -10,3 +10,4 @@ pub use common::UserModel;
pub use ui::SelectedView; pub use ui::SelectedView;
pub use common::BorderArea; pub use common::BorderArea;
pub use common::ClipboardData;

View File

@@ -137,6 +137,54 @@ set_area_border_types = r"""
setAreaWithBorder(area: Area, border_area: BorderArea): void; setAreaWithBorder(area: Area, border_area: BorderArea): void;
""" """
paste_csv_string = r"""
/**
* @param {any} area
* @param {string} csv
*/
pasteCsvText(area: any, csv: string): void;
"""
paste_csv_string_types = r"""
/**
* @param {Area} area
* @param {string} csv
*/
pasteCsvText(area: Area, csv: string): void;
"""
clipboard = r"""
/**
* @returns {any}
*/
copyToClipboard(): any;
"""
clipboard_types = r"""
/**
* @returns {Clipboard}
*/
copyToClipboard(): Clipboard;
"""
paste_from_clipboard = r"""
/**
* @param {any} source_range
* @param {any} clipboard
* @param {boolean} is_cut
*/
pasteFromClipboard(source_range: any, clipboard: any, is_cut: boolean): void;
"""
paste_from_clipboard_types = r"""
/**
* @param {[number, number, number, number]} source_range
* @param {ClipboardData} clipboard
* @param {boolean} is_cut
*/
pasteFromClipboard(source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void;
"""
def fix_types(text): def fix_types(text):
text = text.replace(get_tokens_str, get_tokens_str_types) text = text.replace(get_tokens_str, get_tokens_str_types)
text = text.replace(update_style_str, update_style_str_types) text = text.replace(update_style_str, update_style_str_types)
@@ -147,6 +195,9 @@ def fix_types(text):
text = text.replace(autofill_columns, autofill_columns_types) text = text.replace(autofill_columns, autofill_columns_types)
text = text.replace(set_cell_style, set_cell_style_types) text = text.replace(set_cell_style, set_cell_style_types)
text = text.replace(set_area_border, set_area_border_types) text = text.replace(set_area_border, set_area_border_types)
text = text.replace(paste_csv_string, paste_csv_string_types)
text = text.replace(clipboard, clipboard_types)
text = text.replace(paste_from_clipboard, paste_from_clipboard_types)
with open("types.ts") as f: with open("types.ts") as f:
types_str = f.read() types_str = f.read()
header_types = "{}\n\n{}".format(header, types_str) header_types = "{}\n\n{}".format(header, types_str)

View File

@@ -6,7 +6,7 @@ use wasm_bindgen::{
use ironcalc_base::{ use ironcalc_base::{
expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column}, expressions::{lexer::util::get_tokens as tokenizer, types::Area, utils::number_to_column},
types::{CellType, Style}, types::{CellType, Style},
BorderArea, UserModel as BaseModel, BorderArea, ClipboardData, UserModel as BaseModel,
}; };
fn to_js_error(error: String) -> JsError { fn to_js_error(error: String) -> JsError {
@@ -37,8 +37,8 @@ pub struct Model {
#[wasm_bindgen] #[wasm_bindgen]
impl Model { impl Model {
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
pub fn new(locale: &str, timezone: &str) -> Result<Model, JsError> { pub fn new(name: &str, locale: &str, timezone: &str) -> Result<Model, JsError> {
let model = BaseModel::new_empty("workbook", locale, timezone).map_err(to_js_error)?; let model = BaseModel::new_empty(name, locale, timezone).map_err(to_js_error)?;
Ok(Model { model }) Ok(Model { model })
} }
@@ -97,8 +97,8 @@ impl Model {
} }
#[wasm_bindgen(js_name = "newSheet")] #[wasm_bindgen(js_name = "newSheet")]
pub fn new_sheet(&mut self) { pub fn new_sheet(&mut self) -> Result<(), JsError> {
self.model.new_sheet() self.model.new_sheet().map_err(to_js_error)
} }
#[wasm_bindgen(js_name = "deleteSheet")] #[wasm_bindgen(js_name = "deleteSheet")]
@@ -482,4 +482,53 @@ impl Model {
.map_err(|e| to_js_error(e.to_string()))?; .map_err(|e| to_js_error(e.to_string()))?;
Ok(()) Ok(())
} }
#[wasm_bindgen(js_name = "toBytes")]
pub fn to_bytes(&self) -> Vec<u8> {
self.model.to_bytes()
}
#[wasm_bindgen(js_name = "getName")]
pub fn get_name(&self) -> String {
self.model.get_name()
}
#[wasm_bindgen(js_name = "setName")]
pub fn set_name(&mut self, name: &str) {
self.model.set_name(name);
}
#[wasm_bindgen(js_name = "copyToClipboard")]
pub fn copy_to_clipboard(&self) -> Result<JsValue, JsError> {
let data = self
.model
.copy_to_clipboard()
.map_err(|e| to_js_error(e.to_string()));
data.map(|x| serde_wasm_bindgen::to_value(&x).unwrap())
}
#[wasm_bindgen(js_name = "pasteFromClipboard")]
pub fn paste_from_clipboard(
&mut self,
source_range: JsValue,
clipboard: JsValue,
is_cut: bool,
) -> Result<(), JsError> {
let source_range: (i32, i32, i32, i32) =
serde_wasm_bindgen::from_value(source_range).map_err(|e| to_js_error(e.to_string()))?;
let clipboard: ClipboardData =
serde_wasm_bindgen::from_value(clipboard).map_err(|e| to_js_error(e.to_string()))?;
self.model
.paste_from_clipboard(source_range, &clipboard, is_cut)
.map_err(|e| to_js_error(e.to_string()))
}
#[wasm_bindgen(js_name = "pasteCsvText")]
pub fn paste_csv_string(&mut self, area: JsValue, csv: &str) -> Result<(), JsError> {
let range: Area =
serde_wasm_bindgen::from_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()))
}
} }

View File

@@ -2,8 +2,10 @@ import test from 'node:test';
import assert from 'node:assert' import assert from 'node:assert'
import { Model } from "../pkg/wasm.js"; import { Model } from "../pkg/wasm.js";
const DEFAULT_ROW_HEIGHT = 28;
test('Frozen rows and columns', () => { test('Frozen rows and columns', () => {
let model = new Model('en', 'UTC'); let model = new Model('Workbook1', 'en', 'UTC');
assert.strictEqual(model.getFrozenRowsCount(0), 0); assert.strictEqual(model.getFrozenRowsCount(0), 0);
assert.strictEqual(model.getFrozenColumnsCount(0), 0); assert.strictEqual(model.getFrozenColumnsCount(0), 0);
@@ -15,14 +17,14 @@ test('Frozen rows and columns', () => {
}); });
test('Row height', () => { test('Row height', () => {
let model = new Model('en', 'UTC'); let model = new Model('Workbook1', 'en', 'UTC');
assert.strictEqual(model.getRowHeight(0, 3), 21); assert.strictEqual(model.getRowHeight(0, 3), DEFAULT_ROW_HEIGHT);
model.setRowHeight(0, 3, 32); model.setRowHeight(0, 3, 32);
assert.strictEqual(model.getRowHeight(0, 3), 32); assert.strictEqual(model.getRowHeight(0, 3), 32);
model.undo(); model.undo();
assert.strictEqual(model.getRowHeight(0, 3), 21); assert.strictEqual(model.getRowHeight(0, 3), DEFAULT_ROW_HEIGHT);
model.redo(); model.redo();
assert.strictEqual(model.getRowHeight(0, 3), 32); assert.strictEqual(model.getRowHeight(0, 3), 32);
@@ -32,7 +34,7 @@ test('Row height', () => {
}); });
test('Evaluates correctly', (t) => { test('Evaluates correctly', (t) => {
const model = new Model('en', 'UTC'); const model = new Model('Workbook1', 'en', 'UTC');
model.setUserInput(0, 1, 1, "23"); model.setUserInput(0, 1, 1, "23");
model.setUserInput(0, 1, 2, "=A1*3+1"); model.setUserInput(0, 1, 2, "=A1*3+1");
@@ -41,7 +43,7 @@ test('Evaluates correctly', (t) => {
}); });
test('Styles work', () => { test('Styles work', () => {
const model = new Model('en', 'UTC'); const model = new Model('Workbook1', 'en', 'UTC');
let style = model.getCellStyle(0, 1, 1); let style = model.getCellStyle(0, 1, 1);
assert.deepEqual(style, { assert.deepEqual(style, {
num_fmt: 'general', num_fmt: 'general',
@@ -74,7 +76,7 @@ test('Styles work', () => {
}); });
test("Add sheets", (t) => { test("Add sheets", (t) => {
const model = new Model('en', 'UTC'); const model = new Model('Workbook1', 'en', 'UTC');
model.newSheet(); model.newSheet();
model.renameSheet(1, "NewName"); model.renameSheet(1, "NewName");
let props = model.getWorksheetsProperties(); let props = model.getWorksheetsProperties();
@@ -92,7 +94,7 @@ test("Add sheets", (t) => {
}); });
test("invalid sheet index throws an exception", () => { test("invalid sheet index throws an exception", () => {
const model = new Model('en', 'UTC'); const model = new Model('Workbook1', 'en', 'UTC');
assert.throws(() => { assert.throws(() => {
model.setRowHeight(1, 1, 100); model.setRowHeight(1, 1, 100);
}, { }, {
@@ -102,7 +104,7 @@ test("invalid sheet index throws an exception", () => {
}); });
test("invalid column throws an exception", () => { test("invalid column throws an exception", () => {
const model = new Model('en', 'UTC'); const model = new Model('Workbook1', 'en', 'UTC');
assert.throws(() => { assert.throws(() => {
model.setRowHeight(0, -1, 100); model.setRowHeight(0, -1, 100);
}, { }, {
@@ -112,7 +114,7 @@ test("invalid column throws an exception", () => {
}); });
test("floating column numbers get truncated", () => { test("floating column numbers get truncated", () => {
const model = new Model('en', 'UTC'); const model = new Model('Workbook1', 'en', 'UTC');
model.setRowHeight(0.8, 5.2, 100.5); model.setRowHeight(0.8, 5.2, 100.5);
assert.strictEqual(model.getRowHeight(0.11, 5.99), 100.5); assert.strictEqual(model.getRowHeight(0.11, 5.99), 100.5);
@@ -120,7 +122,7 @@ test("floating column numbers get truncated", () => {
}); });
test("autofill", () => { test("autofill", () => {
const model = new Model('en', 'UTC'); const model = new Model('Workbook1', 'en', 'UTC');
model.setUserInput(0, 1, 1, "23"); model.setUserInput(0, 1, 1, "23");
model.autoFillRows({sheet: 0, row: 1, column: 1, width: 1, height: 1}, 2); model.autoFillRows({sheet: 0, row: 1, column: 1, width: 1, height: 1}, 2);

View File

@@ -205,3 +205,23 @@ export interface SelectedView {
top_row: number; top_row: number;
left_column: number; left_column: number;
} }
// type ClipboardData = {
// [row: number]: {
// [column: number]: ClipboardCell;
// };
// };
// type ClipboardData = Record<string, Record <string, ClipboardCell>>;
type ClipboardData = Map<number, Map <number, ClipboardCell>>;
export interface ClipboardCell {
text: string;
style: CellStyle;
}
export interface Clipboard {
csv: string;
data: ClipboardData;
range: [number, number, number, number];
}

View File

@@ -12,5 +12,6 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script defer data-domain="app.ironcalc.com" src="https://plausible.io/js/script.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,10 @@
#root { #root {
position: absolute; position: absolute;
inset: 10px; inset: 0px;
border: 1px solid #aaa; margin: 0px;
border-radius: 4px; border: none;
}
html,
body {
overscroll-behavior: none;
} }

View File

@@ -4,32 +4,47 @@ import "./i18n";
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import init, { Model } from "@ironcalc/wasm"; import init, { Model } from "@ironcalc/wasm";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FileBar } from "./AppComponents/FileBar";
import { get_model, uploadFile } from "./AppComponents/rpc";
import {
createNewModel,
deleteSelectedModel,
loadModelFromStorageOrCreate,
saveModelToStorage,
saveSelectedModelInStorage,
selectModelFromStorage,
} from "./AppComponents/storage";
import { WorkbookState } from "./components/workbookState"; import { WorkbookState } from "./components/workbookState";
import { IronCalcIcon } from "./icons";
function App() { function App() {
const [model, setModel] = useState<Model | null>(null); const [model, setModel] = useState<Model | null>(null);
const [workbookState, setWorkbookState] = useState<WorkbookState | null>( const [workbookState, setWorkbookState] = useState<WorkbookState | null>(
null, null,
); );
useEffect(() => { useEffect(() => {
async function start() { async function start() {
await init(); await init();
const queryString = window.location.search; const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString); const urlParams = new URLSearchParams(queryString);
const modelName = urlParams.get("model"); const modelHash = urlParams.get("model");
// If there is a model name ?model=example.ic we try to load it // If there is a model name ?model=modelHash we try to load it
// if there is not, or the loading failed we load an empty model // if there is not, or the loading failed we load an empty model
if (modelName) { if (modelHash) {
// Get a remote model
try { try {
const model_bytes = new Uint8Array( const model_bytes = await get_model(modelHash);
await (await fetch(`./${modelName}`)).arrayBuffer(), const importedModel = Model.from_bytes(model_bytes);
); localStorage.removeItem("selected");
setModel(Model.from_bytes(model_bytes)); setModel(importedModel);
} catch (e) { } catch (e) {
setModel(new Model("en", "UTC")); alert("Model not found, or failed to load");
} }
} else { } else {
setModel(new Model("en", "UTC")); // try to load from local storage
const newModel = loadModelFromStorageOrCreate();
setModel(newModel);
} }
setWorkbookState(new WorkbookState()); setWorkbookState(new WorkbookState());
} }
@@ -37,20 +52,77 @@ function App() {
}, []); }, []);
if (!model || !workbookState) { if (!model || !workbookState) {
return <Loading>Loading</Loading>; return (
<Loading>
<IronCalcIcon style={{ width: 24, height: 24, marginBottom: 16 }} />
<div>Loading IronCalc</div>
</Loading>
);
} }
// We try to save the model every second
setInterval(() => {
const queue = model.flushSendQueue();
if (queue.length !== 1) {
saveSelectedModelInStorage(model);
}
}, 1000);
// We could use context for model, but the problem is that it should initialized to null. // We could use context for model, but the problem is that it should initialized to null.
// Passing the property down makes sure it is always defined. // Passing the property down makes sure it is always defined.
return <Workbook model={model} workbookState={workbookState} />;
return (
<Wrapper>
<FileBar
model={model}
onModelUpload={async (arrayBuffer: ArrayBuffer, fileName: string) => {
const blob = await uploadFile(arrayBuffer, fileName);
const bytes = new Uint8Array(await blob.arrayBuffer());
const newModel = Model.from_bytes(bytes);
saveModelToStorage(newModel);
setModel(newModel);
}}
newModel={() => {
setModel(createNewModel());
}}
setModel={(uuid: string) => {
const newModel = selectModelFromStorage(uuid);
if (newModel) {
setModel(newModel);
}
}}
onDelete={() => {
const newModel = deleteSelectedModel();
if (newModel) {
setModel(newModel);
}
}}
/>
<Workbook model={model} workbookState={workbookState} />
</Wrapper>
);
} }
const Wrapper = styled("div")`
margin: 0px;
padding: 0px;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: absolute;
`;
const Loading = styled("div")` const Loading = styled("div")`
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 36px; font-family: 'Inter';
font-size: 14px;
`; `;
export default App; export default App;

View File

@@ -0,0 +1,124 @@
import styled from "@emotion/styled";
import type { Model } from "@ironcalc/wasm";
import { CircleCheck } from "lucide-react";
import { useRef, useState } from "react";
import { IronCalcIcon, IronCalcLogo } from "./../icons";
import { FileMenu } from "./FileMenu";
import { ShareButton } from "./ShareButton";
import { WorkbookTitle } from "./WorkbookTitle";
import { downloadModel, shareModel } from "./rpc";
import { updateNameSelectedWorkbook } from "./storage";
export function FileBar(properties: {
model: Model;
newModel: () => void;
setModel: (key: string) => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void;
}) {
const hiddenInputRef = useRef<HTMLInputElement>(null);
const [toast, setToast] = useState(false);
return (
<FileBarWrapper>
<StyledDesktopLogo />
<StyledIronCalcIcon />
<Divider />
<FileMenu
newModel={properties.newModel}
setModel={properties.setModel}
onModelUpload={properties.onModelUpload}
onDownload={async () => {
const model = properties.model;
const bytes = model.toBytes();
const fileName = model.getName();
await downloadModel(bytes, fileName);
}}
onDelete={properties.onDelete}
/>
<WorkbookTitle
name={properties.model.getName()}
onNameChange={(name) => {
properties.model.setName(name);
updateNameSelectedWorkbook(properties.model, name);
}}
/>
<input
ref={hiddenInputRef}
type="text"
style={{ position: "absolute", left: -9999, top: -9999 }}
/>
<div style={{ marginLeft: "auto" }}>
{toast ? (
<Toast>
<CircleCheck style={{ width: 12 }} />
<span
style={{ marginLeft: 8, marginRight: 12, fontFamily: "Inter" }}
>
URL copied to clipboard
</span>
</Toast>
) : (
""
)}
</div>
<ShareButton
onClick={async () => {
const model = properties.model;
const bytes = model.toBytes();
const fileName = model.getName();
const hash = await shareModel(bytes, fileName);
const value = `${location.origin}/?model=${hash}`;
if (hiddenInputRef.current) {
hiddenInputRef.current.value = value;
hiddenInputRef.current.select();
document.execCommand("copy");
setToast(true);
setTimeout(() => setToast(false), 5000);
}
console.log(value);
}}
/>
</FileBarWrapper>
);
}
const StyledDesktopLogo = styled(IronCalcLogo)`
width: 120px;
margin-left: 10px;
@media (max-width: 769px) {
display: none;
}
`;
const StyledIronCalcIcon = styled(IronCalcIcon)`
width: 36px;
margin-left: 10px;
@media (min-width: 769px) {
display: none;
}
`;
const Toast = styled("div")`
font-weight: 400;
font-size: 12px;
color: #9e9e9e;
display: flex;
align-items: center;
`;
const Divider = styled("div")`
margin: 10px;
height: 12px;
border-left: 1px solid #e0e0e0;
`;
const FileBarWrapper = styled("div")`
height: 60px;
width: 100%;
background: #fff;
display: flex;
align-items: center;
border-bottom: 1px solid #e0e0e0;
position: relative;
justify-content: space-between;
`;

View File

@@ -0,0 +1,184 @@
import styled from "@emotion/styled";
import { Menu, MenuItem, Modal } from "@mui/material";
import { FileDown, FileUp, Plus, Trash2 } from "lucide-react";
import { useRef, useState } from "react";
import { UploadFileDialog } from "./UploadFileDialog";
import { getModelsMetadata, getSelectedUuid } from "./storage";
export function FileMenu(props: {
newModel: () => void;
setModel: (key: string) => void;
onDownload: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
onDelete: () => void;
}) {
const [isMenuOpen, setMenuOpen] = useState(false);
const [isImportMenuOpen, setImportMenuOpen] = useState(false);
const anchorElement = useRef<HTMLDivElement>(null);
const models = getModelsMetadata();
const uuids = Object.keys(models);
const selectedUuid = getSelectedUuid();
const elements = [];
for (const uuid of uuids) {
elements.push(
<MenuItemWrapper
key={uuid}
onClick={() => {
props.setModel(uuid);
setMenuOpen(false);
}}
>
<span style={{ width: "20px" }}>
{uuid === selectedUuid ? "•" : ""}
</span>
<MenuItemText
style={{
maxWidth: "240px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{models[uuid]}
</MenuItemText>
</MenuItemWrapper>,
);
}
return (
<>
<FileMenuWrapper
onClick={(): void => setMenuOpen(true)}
ref={anchorElement}
>
File
</FileMenuWrapper>
<Menu
open={isMenuOpen}
onClose={(): void => setMenuOpen(false)}
anchorEl={anchorElement.current}
// anchorOrigin={properties.anchorOrigin}
>
<MenuItemWrapper onClick={props.newModel}>
<StyledPlus />
<MenuItemText>New</MenuItemText>
</MenuItemWrapper>
<MenuItemWrapper
onClick={() => {
setImportMenuOpen(true);
setMenuOpen(false);
}}
>
<StyledFileUp />
<MenuItemText>Import</MenuItemText>
</MenuItemWrapper>
<MenuItemWrapper>
<StyledFileDown />
<MenuItemText onClick={props.onDownload}>
Download (.xlsx)
</MenuItemText>
</MenuItemWrapper>
<MenuItemWrapper>
<StyledTrash />
<MenuItemText
onClick={() => {
props.onDelete();
setMenuOpen(false);
}}
>
Delete workbook
</MenuItemText>
</MenuItemWrapper>
<MenuDivider />
{elements}
</Menu>
<Modal
open={isImportMenuOpen}
onClose={() => {
const root = document.getElementById("root");
if (root) {
root.style.filter = "";
}
setImportMenuOpen(false);
}}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<>
<UploadFileDialog
onClose={() => {
const root = document.getElementById("root");
if (root) {
root.style.filter = "";
}
setImportMenuOpen(false);
}}
onModelUpload={props.onModelUpload}
/>
</>
</Modal>
</>
);
}
const StyledPlus = styled(Plus)`
width: 16px;
height: 16px;
color: #333333;
padding-right: 10px;
`;
const StyledFileDown = styled(FileDown)`
width: 16px;
height: 16px;
color: #333333;
padding-right: 10px;
`;
const StyledFileUp = styled(FileUp)`
width: 16px;
height: 16px;
color: #333333;
padding-right: 10px;
`;
const StyledTrash = styled(Trash2)`
width: 16px;
height: 16px;
color: #333333;
padding-right: 10px;
`;
const MenuDivider = styled("div")`
width: 80%;
margin: auto;
margin-top: 8px;
margin-bottom: 8px;
border-top: 1px solid #e0e0e0;
`;
const MenuItemText = styled("div")`
color: #000;
font-size: 12px;
`;
const MenuItemWrapper = styled(MenuItem)`
display: flex;
justify-content: flex-start;
font-size: 14px;
width: 100%;
`;
const FileMenuWrapper = styled("div")`
display: flex;
align-items: center;
font-size: 12px;
font-family: Inter;
padding: 10px;
height: 20px;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #f2f2f2;
}
`;

View File

@@ -0,0 +1,30 @@
import styled from "@emotion/styled";
import { Share2 } from "lucide-react";
export function ShareButton(properties: { onClick: () => void }) {
const { onClick } = properties;
return (
<Wrapper onClick={onClick} onKeyDown={() => {}}>
<Share2 style={{ width: "16px", height: "16px", marginRight: "10px" }} />
<span>Share</span>
</Wrapper>
);
}
const Wrapper = styled("div")`
cursor: pointer;
color: #ffffff;
background: #f2994a;
padding: 0px 10px;
height: 36px;
line-height: 36px;
border-radius: 4px;
margin-right: 10px;
display: flex;
align-items: center;
font-family: "Inter";
font-size: 14px;
&:hover {
background: #d68742;
}
`;

View File

@@ -0,0 +1,271 @@
import styled from "@emotion/styled";
import { BookOpen, FileUp } from "lucide-react";
import { type DragEvent, useRef, useState } from "react";
export function UploadFileDialog(properties: {
onClose: () => void;
onModelUpload: (blob: ArrayBuffer, fileName: string) => Promise<void>;
}) {
const [hover, setHover] = useState(false);
const [message, setMessage] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const { onModelUpload } = properties;
const handleClose = () => {
properties.onClose();
};
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setHover(true);
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setHover(true);
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setHover(false);
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const dt = event.dataTransfer;
const items = dt.items;
if (items) {
// Use DataTransferItemList to access the file(s)
for (let i = 0; i < items.length; i++) {
// If dropped items aren't files, skip them
if (items[i].kind === "file") {
const file = items[i].getAsFile();
if (file) {
handleFileUpload(file);
return;
}
}
}
} else {
const files = dt.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
}
};
const handleFileUpload = (file: File) => {
setMessage(`Uploading ${file.name}...`);
// Read the file as ArrayBuffer
const reader = new FileReader();
reader.onload = async () => {
try {
await onModelUpload(reader.result as ArrayBuffer, file.name);
handleClose();
} catch (e) {
console.log("error", e);
setMessage(`${e}`);
}
};
reader.readAsArrayBuffer(file);
};
const root = document.getElementById("root");
if (root) {
root.style.filter = "blur(4px)";
}
return (
<UploadDialog>
<UploadTitle>
<span style={{ flexGrow: 2, marginLeft: 12 }}>
Import an .xlsx file
</span>
<Cross
style={{ marginRight: 12 }}
onClick={handleClose}
onKeyDown={() => {}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Close</title>
<path
d="M12 4.5L4 12.5"
stroke="#333333"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 4.5L12 12.5"
stroke="#333333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Cross>
</UploadTitle>
{message === "" ? (
<DropZone
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDragExit={handleDragLeave}
onDrop={handleDrop}
>
{!hover ? (
<>
<div style={{ flexGrow: 2 }} />
<div>
<FileUp
style={{
width: 16,
color: "#EFAA6D",
backgroundColor: "#F2994A1A",
padding: "2px 4px",
borderRadius: 4,
}}
/>
</div>
<div style={{ fontSize: 12 }}>
<span style={{ color: "#333333" }}>
Drag and drop a file here or{" "}
</span>
<input
ref={fileInputRef}
type="file"
multiple
accept="*"
style={{ display: "none" }}
onChange={(event) => {
const files = event.target.files;
if (files) {
for (const file of files) {
handleFileUpload(file);
}
}
}}
/>
<DocLink
onClick={() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}}
>
click to browse
</DocLink>
</div>
<div style={{ flexGrow: 2 }} />
</>
) : (
<>
<div style={{ flexGrow: 2 }} />
<div>Drop file here</div>
<div style={{ flexGrow: 2 }} />
</>
)}
</DropZone>
) : (
<DropZone>
<>
<div style={{ flexGrow: 2 }} />
<div>{message}</div>
<div style={{ flexGrow: 2 }} />
</>
</DropZone>
)}
<UploadFooter>
<BookOpen
style={{ width: 16, height: 16, marginLeft: 12, marginRight: 8 }}
/>
<span>Learn more about importing files into IronCalc</span>
</UploadFooter>
</UploadDialog>
);
}
const Cross = styled("div")`
&:hover {
background-color: #f5f5f5;
}
border-radius: 4px;
height: 16px;
width: 16px;
`;
const DocLink = styled("span")`
color: #f2994a;
text-decoration: underline;
&:hover {
font-weight: bold;
}
`;
const UploadFooter = styled("div")`
height: 40px;
border-top: 1px solid #e0e0e0;
font-size: 12px;
font-weight: 400;
color: #757575;
display: flex;
align-items: center;
`;
const UploadTitle = styled("div")`
display: flex;
align-items: center;
border-bottom: 1px solid #e0e0e0;
height: 40px;
font-size: 14px;
font-weight: 500;
`;
const UploadDialog = styled("div")`
display: flex;
flex-direction: column;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 455px;
height: 285px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0px 1px 3px 0px #0000001a;
font-family: Inter;
`;
const DropZone = styled("div")`
flex-grow: 2;
border-radius: 10px;
text-align: center;
margin: 12px;
color: #aaa;
font-family: Arial, sans-serif;
cursor: pointer;
background-color: #faebd7;
border: 1px dashed #f2994a;
background: linear-gradient(
180deg,
rgba(242, 153, 74, 0.08) 0%,
rgba(242, 153, 74, 0) 100%
);
display: flex;
flex-direction: column;
vertical-align: center;
`;

View File

@@ -0,0 +1,104 @@
import styled from "@emotion/styled";
import { type ChangeEvent, useEffect, useRef, useState } from "react";
export function WorkbookTitle(props: {
name: string;
onNameChange: (name: string) => void;
}) {
const [width, setWidth] = useState(0);
const [value, setValue] = useState(props.name);
const mirrorDivRef = useRef<HTMLDivElement>(null);
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
setValue(event.target.value);
if (mirrorDivRef.current) {
setWidth(mirrorDivRef.current.scrollWidth);
}
};
useEffect(() => {
if (mirrorDivRef.current) {
setWidth(mirrorDivRef.current.scrollWidth);
}
}, []);
useEffect(() => {
setValue(props.name);
}, [props.name]);
return (
<div
style={{
position: "absolute",
left: "50%",
textAlign: "center",
transform: "translateX(-50%)",
// height: "60px",
// lineHeight: "60px",
padding: "8px",
fontSize: "14px",
fontWeight: "700",
fontFamily: "Inter",
width,
}}
>
<TitleWrapper
value={value}
rows={1}
onChange={handleChange}
onBlur={(event) => {
props.onNameChange(event.target.value);
}}
style={{ width: width }}
spellCheck="false"
>
{value}
</TitleWrapper>
<div
ref={mirrorDivRef}
style={{
position: "absolute",
top: "-9999px",
left: "-9999px",
whiteSpace: "pre-wrap",
textWrap: "nowrap",
visibility: "hidden",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
padding: "inherit",
border: "inherit",
}}
>
{value}
</div>
</div>
);
}
const TitleWrapper = styled("textarea")`
vertical-align: middle;
text-align: center;
height: 20px;
line-height: 20px;
border-radius: 4px;
padding: inherit;
overflow: hidden;
outline: none;
resize: none;
text-wrap: nowrap;
border: none;
&:hover {
background-color: #f2f2f2;
}
&:focus {
border: 1px solid grey;
}
font-weight: inherit;
font-family: inherit;
font-size: inherit;
max-width: 520px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;

View File

@@ -0,0 +1,70 @@
export async function uploadFile(
arrayBuffer: ArrayBuffer,
fileName: string,
): Promise<Blob> {
// Fetch request to upload the file
const response = await fetch(`/api/upload/${fileName}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
body: arrayBuffer,
});
const blob = await response.blob();
return blob;
}
export async function get_model(modelHash: string): Promise<Uint8Array> {
return new Uint8Array(
await (await fetch(`/api/model/${modelHash}`)).arrayBuffer(),
);
}
export async function downloadModel(bytes: Uint8Array, fileName: string) {
const response = await fetch("/api/download", {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
body: bytes,
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
const blob = await response.blob();
// Create a link element and trigger a download
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
// Use the same filename or change as needed
a.download = `${fileName}.xlsx`;
document.body.appendChild(a);
a.click();
// Clean up
window.URL.revokeObjectURL(url);
a.remove();
}
export async function shareModel(
bytes: Uint8Array,
fileName: string,
): Promise<string> {
const response = await fetch("/api/share", {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
body: bytes,
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
return await response.text();
}

View File

@@ -0,0 +1,129 @@
import { Model } from "@ironcalc/wasm";
import { base64ToBytes, bytesToBase64 } from "./util";
const MAX_WORKBOOKS = 50;
type ModelsMetadata = Record<string, string>;
export function updateNameSelectedWorkbook(model: Model, newName: string) {
const uuid = localStorage.getItem("selected");
if (uuid) {
const modelsJson = localStorage.getItem("models");
if (modelsJson) {
try {
const models = JSON.parse(modelsJson);
models[uuid] = newName;
localStorage.setItem("models", JSON.stringify(models));
} catch (e) {
console.warn("Failed saving new name");
}
}
const modeBytes = model.toBytes();
localStorage.setItem(uuid, bytesToBase64(modeBytes));
}
}
export function getModelsMetadata(): ModelsMetadata {
let modelsJson = localStorage.getItem("models");
if (!modelsJson) {
modelsJson = "{}";
}
return JSON.parse(modelsJson);
}
// Pick a different name Workbook{N} where N = 1, 2, 3
function getNewName(existingNames: string[]): string {
const baseName = "Workbook";
let index = 1;
while (index < MAX_WORKBOOKS) {
const name = `${baseName}${index}`;
index += 1;
if (!existingNames.includes(name)) {
return name;
}
}
// FIXME: Too many workbooks?
return "Workbook-Infinity";
}
export function createNewModel(): Model {
const models = getModelsMetadata();
const name = getNewName(Object.values(models));
const model = new Model(name, "en", "UTC");
const uuid = crypto.randomUUID();
localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
models[uuid] = name;
localStorage.setItem("models", JSON.stringify(models));
return model;
}
export function loadModelFromStorageOrCreate(): Model {
const uuid = localStorage.getItem("selected");
if (uuid) {
// We try to load the selected model
const modelBytesString = localStorage.getItem(uuid);
if (modelBytesString) {
return Model.from_bytes(base64ToBytes(modelBytesString));
}
// If it doesn't exist we create one at that uuid
const newModel = new Model("Workbook1", "en", "UTC");
localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(newModel.toBytes()));
return newModel;
}
// If there was no selected model we create a new one
return createNewModel();
}
export function saveSelectedModelInStorage(model: Model) {
const uuid = localStorage.getItem("selected");
if (uuid) {
const modeBytes = model.toBytes();
localStorage.setItem(uuid, bytesToBase64(modeBytes));
}
}
export function saveModelToStorage(model: Model) {
const uuid = crypto.randomUUID();
localStorage.setItem("selected", uuid);
localStorage.setItem(uuid, bytesToBase64(model.toBytes()));
let modelsJson = localStorage.getItem("models");
if (!modelsJson) {
modelsJson = "{}";
}
const models = JSON.parse(modelsJson);
models[uuid] = model.getName();
localStorage.setItem("models", JSON.stringify(models));
}
export function selectModelFromStorage(uuid: string): Model | null {
localStorage.setItem("selected", uuid);
const modelBytesString = localStorage.getItem(uuid);
if (modelBytesString) {
return Model.from_bytes(base64ToBytes(modelBytesString));
}
return null;
}
export function getSelectedUuid(): string | null {
return localStorage.getItem("selected");
}
export function deleteSelectedModel(): Model | null {
const uuid = localStorage.getItem("selected");
if (!uuid) {
return null;
}
localStorage.removeItem(uuid);
const metadata = getModelsMetadata();
delete metadata[uuid];
localStorage.setItem("models", JSON.stringify(metadata));
const uuids = Object.keys(metadata);
if (uuids.length === 0) {
return createNewModel();
}
return selectModelFromStorage(uuids[0]);
}

View File

@@ -0,0 +1,18 @@
export function base64ToBytes(base64: string): Uint8Array {
// const binString = atob(base64);
// return Uint8Array.from(binString, (m) => m.codePointAt(0));
return new Uint8Array(
atob(base64)
.split("")
.map((c) => c.charCodeAt(0)),
);
}
export function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte),
).join("");
// btoa(String.fromCharCode(...bytes));
return btoa(binString);
}

View File

@@ -7,7 +7,7 @@ export const headerFullSelectedBackground = "#D3D6E9";
export const headerSelectedColor = "#333"; export const headerSelectedColor = "#333";
export const headerBorderColor = "#DEE0EF"; export const headerBorderColor = "#DEE0EF";
export const gridColor = "#D3D6E9"; export const gridColor = "#E0E0E0";
export const gridSeparatorColor = "#D3D6E9"; export const gridSeparatorColor = "#D3D6E9";
export const defaultTextColor = "#2E414D"; export const defaultTextColor = "#2E414D";
@@ -16,3 +16,6 @@ export const outlineBackgroundColor = "#F2994A1A";
export const LAST_COLUMN = 16_384; export const LAST_COLUMN = 16_384;
export const LAST_ROW = 1_048_576; export const LAST_ROW = 1_048_576;
export const ROW_HEIGH_SCALE = 1;
export const COLUMN_WIDTH_SCALE = 1;

View File

@@ -1,10 +1,13 @@
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 type { Cell } from "../types"; import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState"; import type { WorkbookState } from "../workbookState";
import { import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN, LAST_COLUMN,
LAST_ROW, LAST_ROW,
ROW_HEIGH_SCALE,
defaultTextColor, defaultTextColor,
gridColor, gridColor,
gridSeparatorColor, gridSeparatorColor,
@@ -30,6 +33,7 @@ export interface CanvasSettings {
columnGuide: HTMLDivElement; columnGuide: HTMLDivElement;
rowGuide: HTMLDivElement; rowGuide: HTMLDivElement;
columnHeaders: HTMLDivElement; columnHeaders: HTMLDivElement;
editor: HTMLDivElement;
}; };
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;
@@ -40,7 +44,7 @@ export const fonts = {
mono: '"Fira Mono", "Adjusted Courier New Fallback", serif', mono: '"Fira Mono", "Adjusted Courier New Fallback", serif',
}; };
export const headerRowHeight = 24; export const headerRowHeight = 28;
export const headerColumnWidth = 30; export const headerColumnWidth = 30;
export const devicePixelRatio = window.devicePixelRatio || 1; export const devicePixelRatio = window.devicePixelRatio || 1;
@@ -48,6 +52,23 @@ export const defaultCellFontFamily = fonts.regular;
export const headerFontFamily = fonts.regular; export const headerFontFamily = fonts.regular;
export const frozenSeparatorWidth = 3; export const frozenSeparatorWidth = 3;
// Get a 10% transparency of an hex color
function hexToRGBA10Percent(colorHex: string): string {
// Remove the leading hash (#) if present
const hex = colorHex.replace(/^#/, "");
// Parse the hex color
const red = Number.parseInt(hex.substring(0, 2), 16);
const green = Number.parseInt(hex.substring(2, 4), 16);
const blue = Number.parseInt(hex.substring(4, 6), 16);
// Set the alpha (opacity) to 0.1 (10%)
const alpha = 0.1;
// Return the RGBA color string
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
export default class WorksheetCanvas { export default class WorksheetCanvas {
sheetWidth: number; sheetWidth: number;
@@ -61,6 +82,8 @@ export default class WorksheetCanvas {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
editor: HTMLDivElement;
areaOutline: HTMLDivElement; areaOutline: HTMLDivElement;
cellOutline: HTMLDivElement; cellOutline: HTMLDivElement;
@@ -92,6 +115,7 @@ export default class WorksheetCanvas {
this.height = options.height; this.height = options.height;
this.ctx = this.setContext(); this.ctx = this.setContext();
this.workbookState = options.workbookState; this.workbookState = options.workbookState;
this.editor = options.elements.editor;
this.cellOutline = options.elements.cellOutline; this.cellOutline = options.elements.cellOutline;
this.cellOutlineHandle = options.elements.cellOutlineHandle; this.cellOutlineHandle = options.elements.cellOutlineHandle;
@@ -469,9 +493,11 @@ export default class WorksheetCanvas {
context.clip(); context.clip();
// Is there any better parameter? // Is there any better parameter?
const lineHeight = fontSize; const lineHeight = 22;
const lines = fullText.split("\n");
const lineCount = lines.length;
fullText.split("\n").forEach((text, line) => { lines.forEach((text, line) => {
const textWidth = context.measureText(text).width; const textWidth = context.measureText(text).width;
let textX: number; let textX: number;
let textY: number; let textY: number;
@@ -487,14 +513,18 @@ export default class WorksheetCanvas {
textX = padding + x + textWidth / 2; textX = padding + x + textWidth / 2;
} }
if (verticalAlign === "bottom") { if (verticalAlign === "bottom") {
textY = y + height - fontSize / 2 - verticalPadding; textY =
y +
height -
fontSize / 2 -
verticalPadding +
(line - lineCount + 1) * lineHeight;
} else if (verticalAlign === "center") { } else if (verticalAlign === "center") {
textY = y + height / 2; textY = y + height / 2 + (line + (1 - lineCount) / 2) * lineHeight;
} else { } else {
// aligned top // aligned top
textY = y + fontSize / 2 + verticalPadding; textY = y + fontSize / 2 + verticalPadding + line * lineHeight;
} }
textY += line * lineHeight;
context.fillText(text, textX, textY); context.fillText(text, textX, textY);
if (style.font) { if (style.font) {
if (style.font.u) { if (style.font.u) {
@@ -653,15 +683,15 @@ export default class WorksheetCanvas {
const rowHeight = this.getRowHeight(selectedSheet, row); const rowHeight = this.getRowHeight(selectedSheet, row);
const selected = row >= rowStart && row <= rowEnd; const selected = row >= rowStart && row <= rowEnd;
context.fillStyle = headerBorderColor; context.fillStyle = headerBorderColor;
context.fillRect(0, topLeftCornerY, headerColumnWidth, rowHeight); context.fillRect(0.5, topLeftCornerY, headerColumnWidth, rowHeight);
context.fillStyle = selected context.fillStyle = selected
? headerSelectedBackground ? headerSelectedBackground
: headerBackground; : headerBackground;
context.fillRect( context.fillRect(
1, 0.5,
topLeftCornerY + 1, topLeftCornerY + 0.5,
headerColumnWidth - 2, headerColumnWidth,
rowHeight - 2, rowHeight - 1,
); );
if (selected) { if (selected) {
context.fillStyle = outlineColor; context.fillStyle = outlineColor;
@@ -1025,78 +1055,85 @@ export default class WorksheetCanvas {
rowEnd, rowEnd,
columnEnd, columnEnd,
); );
// const { border } = extendToArea;
extendToOutline.style.border = `1px dashed ${outlineColor}`; extendToOutline.style.border = `1px dashed ${outlineColor}`;
extendToOutline.style.borderRadius = "3px"; extendToOutline.style.borderRadius = "3px";
// switch (border) {
// case 'left': {
// extendToOutline.style.borderLeft = 'none';
// extendToOutline.style.borderTopLeftRadius = '0px';
// extendToOutline.style.borderBottomLeftRadius = '0px';
// break; extendToOutline.style.left = `${areaX}px`;
// } extendToOutline.style.top = `${areaY}px`;
// case 'right': { extendToOutline.style.width = `${areaWidth - 1}px`;
// extendToOutline.style.borderRight = 'none'; extendToOutline.style.height = `${areaHeight - 1}px`;
// extendToOutline.style.borderTopRightRadius = '0px';
// extendToOutline.style.borderBottomRightRadius = '0px';
// break;
// }
// case 'top': {
// extendToOutline.style.borderTop = 'none';
// extendToOutline.style.borderTopRightRadius = '0px';
// extendToOutline.style.borderTopLeftRadius = '0px';
// break;
// }
// case 'bottom': {
// extendToOutline.style.borderBottom = 'none';
// extendToOutline.style.borderBottomRightRadius = '0px';
// extendToOutline.style.borderBottomLeftRadius = '0px';
// break;
// }
// default:
// break;
// }
const padding = 1;
extendToOutline.style.left = `${areaX - padding}px`;
extendToOutline.style.top = `${areaY - padding}px`;
extendToOutline.style.width = `${areaWidth + 2 * padding}px`;
extendToOutline.style.height = `${areaHeight + 2 * padding}px`;
} }
private getColumnWidth(sheet: number, column: number): number { private getColumnWidth(sheet: number, column: number): number {
return Math.round(this.model.getColumnWidth(sheet, column)); return Math.round(
this.model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE,
);
} }
private getRowHeight(sheet: number, row: number): number { private getRowHeight(sheet: number, row: number): number {
return Math.round(this.model.getRowHeight(sheet, row)); return Math.round(this.model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE);
}
private drawCellEditor(): void {
const cell = this.workbookState.getEditingCell();
const selectedSheet = this.model.getSelectedSheet();
const { editor } = this;
if (!cell || cell.sheet !== selectedSheet) {
// If the editing cell is not in the same sheet as the selected sheet
// we take the editor out of view
editor.style.left = "-9999px";
editor.style.top = "-9999px";
return;
}
const { row, column } = cell;
// const style = this.model.getCellStyle(
// selectedSheet,
// selectedRow,
// selectedColumn
// );
// cellOutline.style.fontWeight = style.font.b ? "bold" : "normal";
// cellOutline.style.fontStyle = style.font.i ? "italic" : "normal";
// cellOutline.style.backgroundColor = style.fill.fg_color;
// TODO: Should we add the same color as the text?
// Only if it is not a formula?
// cellOutline.style.color = style.font.color;
const [x, y] = this.getCoordinatesByCell(row, column);
const padding = -1;
const width = cell.editorWidth + 2 * padding;
const height = cell.editorHeight + 2 * padding;
// const width =
// this.getColumnWidth(sheet, column) + 2 * padding;
// const height = this.getRowHeight(sheet, row) + 2 * padding;
editor.style.left = `${x}px`;
editor.style.top = `${y}px`;
editor.style.width = `${width - 1}px`;
editor.style.height = `${height - 1}px`;
} }
private drawCellOutline(): void { private drawCellOutline(): void {
const { cellOutline, areaOutline, cellOutlineHandle } = this;
if (this.workbookState.getEditingCell()) {
cellOutline.style.visibility = "hidden";
cellOutlineHandle.style.visibility = "hidden";
areaOutline.style.visibility = "hidden";
return;
}
cellOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible";
areaOutline.style.visibility = "visible";
const [selectedSheet, selectedRow, selectedColumn] = const [selectedSheet, selectedRow, selectedColumn] =
this.model.getSelectedCell(); this.model.getSelectedCell();
const { topLeftCell } = this.getVisibleCells(); const { topLeftCell } = this.getVisibleCells();
const frozenRows = this.model.getFrozenRowsCount(selectedSheet); const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet); const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
const [x, y] = this.getCoordinatesByCell(selectedRow, selectedColumn); const [x, y] = this.getCoordinatesByCell(selectedRow, selectedColumn);
const style = this.model.getCellStyle(
selectedSheet,
selectedRow,
selectedColumn,
);
const padding = -1; const padding = -1;
const width = const width =
this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding; this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding;
const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding; const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding;
const { cellOutline, areaOutline, cellOutlineHandle } = this;
const cellEditing = null;
cellOutline.style.visibility = "visible";
cellOutlineHandle.style.visibility = "visible";
if ( if (
(selectedRow < topLeftCell.row && selectedRow > frozenRows) || (selectedRow < topLeftCell.row && selectedRow > frozenRows) ||
(selectedColumn < topLeftCell.column && selectedColumn > frozenColumns) (selectedColumn < topLeftCell.column && selectedColumn > frozenColumns)
@@ -1106,8 +1143,8 @@ export default class WorksheetCanvas {
} }
// Position the cell outline and clip it // Position the cell outline and clip it
cellOutline.style.left = `${x - padding}px`; cellOutline.style.left = `${x - padding - 2}px`;
cellOutline.style.top = `${y - padding}px`; cellOutline.style.top = `${y - padding - 2}px`;
// Reset CSS properties // Reset CSS properties
cellOutline.style.minWidth = ""; cellOutline.style.minWidth = "";
cellOutline.style.minHeight = ""; cellOutline.style.minHeight = "";
@@ -1115,18 +1152,11 @@ export default class WorksheetCanvas {
cellOutline.style.maxHeight = ""; cellOutline.style.maxHeight = "";
cellOutline.style.overflow = "hidden"; cellOutline.style.overflow = "hidden";
// New properties // New properties
cellOutline.style.width = `${width}px`; cellOutline.style.width = `${width + 1}px`;
cellOutline.style.height = `${height}px`; cellOutline.style.height = `${height + 1}px`;
if (cellEditing) {
cellOutline.style.fontWeight = style.font.b ? "bold" : "normal"; cellOutline.style.background = "none";
cellOutline.style.fontStyle = style.font.i ? "italic" : "normal";
// cellOutline.style.backgroundColor = style.fill.fg_color;
// TODO: Should we add the same color as the text?
// Only if it is not a formula?
// cellOutline.style.color = style.font.color;
} else {
cellOutline.style.background = "none";
}
// border is 2px so line-height must be height - 4 // border is 2px so line-height must be height - 4
cellOutline.style.lineHeight = `${height - 4}px`; cellOutline.style.lineHeight = `${height - 4}px`;
let { let {
@@ -1158,10 +1188,10 @@ export default class WorksheetCanvas {
); );
handleX = areaX + areaWidth; handleX = areaX + areaWidth;
handleY = areaY + areaHeight; handleY = areaY + areaHeight;
areaOutline.style.left = `${areaX - padding}px`; areaOutline.style.left = `${areaX - padding - 1}px`;
areaOutline.style.top = `${areaY - padding}px`; areaOutline.style.top = `${areaY - padding - 1}px`;
areaOutline.style.width = `${areaWidth + 2 * padding}px`; areaOutline.style.width = `${areaWidth + 2 * padding + 1}px`;
areaOutline.style.height = `${areaHeight + 2 * padding}px`; areaOutline.style.height = `${areaHeight + 2 * padding + 1}px`;
const clipLeft = rowStart < topLeftCell.row && rowStart > frozenRows; const clipLeft = rowStart < topLeftCell.row && rowStart > frozenRows;
const clipTop = const clipTop =
columnStart < topLeftCell.column && columnStart > frozenColumns; columnStart < topLeftCell.column && columnStart > frozenColumns;
@@ -1202,25 +1232,102 @@ export default class WorksheetCanvas {
} }
} }
// draw the handle
if (cellEditing !== null) {
cellOutlineHandle.style.visibility = "hidden";
return;
}
const handleBBox = cellOutlineHandle.getBoundingClientRect(); const handleBBox = cellOutlineHandle.getBoundingClientRect();
const handleWidth = handleBBox.width; const handleWidth = handleBBox.width;
const handleHeight = handleBBox.height; const handleHeight = handleBBox.height;
cellOutlineHandle.style.left = `${handleX - handleWidth / 2}px`; cellOutlineHandle.style.left = `${handleX - handleWidth / 2 - 1}px`;
cellOutlineHandle.style.top = `${handleY - handleHeight / 2}px`; cellOutlineHandle.style.top = `${handleY - handleHeight / 2 - 1}px`;
}
private drawCutRange(): void {
const range = this.workbookState.getCutRange() || null;
if (!range) {
return;
}
const selectedSheet = this.model.getSelectedSheet();
if (range.sheet !== selectedSheet) {
return;
}
const ctx = this.ctx;
ctx.setLineDash([2, 2]);
const [xStart, yStart] = this.getCoordinatesByCell(
range.rowStart,
range.columnStart,
);
const [xEnd, yEnd] = this.getCoordinatesByCell(
range.rowEnd + 1,
range.columnEnd + 1,
);
ctx.strokeStyle = "red";
ctx.lineWidth = 1;
ctx.strokeRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
ctx.setLineDash([]);
}
private drawActiveRanges(topLeftCell: Cell, bottomRightCell: Cell): void {
let activeRanges = this.workbookState.getActiveRanges();
const ctx = this.ctx;
ctx.setLineDash([2, 2]);
const referencedRange =
this.workbookState.getEditingCell()?.referencedRange || null;
if (referencedRange) {
activeRanges = activeRanges.concat([
{
...referencedRange.range,
color: getColor(activeRanges.length),
},
]);
}
const selectedSheet = this.model.getSelectedSheet();
const activeRangesCount = activeRanges.length;
for (let rangeIndex = 0; rangeIndex < activeRangesCount; rangeIndex += 1) {
const range = activeRanges[rangeIndex];
if (range.sheet !== selectedSheet) {
continue;
}
const allowedOffset = 1; // to make borders look nicer
const minRow = topLeftCell.row - allowedOffset;
const maxRow = bottomRightCell.row + allowedOffset;
const minColumn = topLeftCell.column - allowedOffset;
const maxColumn = bottomRightCell.column + allowedOffset;
if (
minRow <= range.rowEnd &&
range.rowStart <= maxRow &&
minColumn <= range.columnEnd &&
range.columnStart < maxColumn
) {
// Range in the viewport.
const displayRange: typeof range = {
...range,
rowStart: Math.max(minRow, range.rowStart),
rowEnd: Math.min(maxRow, range.rowEnd),
columnStart: Math.max(minColumn, range.columnStart),
columnEnd: Math.min(maxColumn, range.columnEnd),
};
const [xStart, yStart] = this.getCoordinatesByCell(
displayRange.rowStart,
displayRange.columnStart,
);
const [xEnd, yEnd] = this.getCoordinatesByCell(
displayRange.rowEnd + 1,
displayRange.columnEnd + 1,
);
ctx.strokeStyle = range.color;
ctx.lineWidth = 1;
ctx.strokeRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
ctx.fillStyle = hexToRGBA10Percent(range.color);
ctx.fillRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
}
}
ctx.setLineDash([]);
} }
renderSheet(): void { renderSheet(): void {
console.time("renderSheet");
this._renderSheet();
console.timeEnd("renderSheet");
}
private _renderSheet(): void {
const context = this.ctx; const context = this.ctx;
const { canvas } = this; const { canvas } = this;
const selectedSheet = this.model.getSelectedSheet(); const selectedSheet = this.model.getSelectedSheet();
@@ -1347,10 +1454,16 @@ export default class WorksheetCanvas {
this.renderRowHeaders(frozenRows, topLeftCell, bottomRightCell); this.renderRowHeaders(frozenRows, topLeftCell, bottomRightCell);
// square in the top left corner // square in the top left corner
context.fillStyle = headerBorderColor; context.beginPath();
context.fillRect(0, 0, headerColumnWidth, headerRowHeight); context.strokeStyle = gridSeparatorColor;
context.moveTo(0, 0.5);
context.lineTo(x + headerColumnWidth, 0.5);
context.stroke();
this.drawCellOutline(); this.drawCellOutline();
this.drawCellEditor();
this.drawExtendToArea(); this.drawExtendToArea();
this.drawActiveRanges(topLeftCell, bottomRightCell);
this.drawCutRange();
} }
} }

View File

@@ -7,7 +7,7 @@ import {
PencilLine, PencilLine,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
BorderBottomIcon, BorderBottomIcon,
@@ -27,6 +27,7 @@ import ColorPicker from "./colorPicker";
type BorderPickerProps = { type BorderPickerProps = {
className?: string; className?: string;
onChange: (border: BorderOptions) => void; onChange: (border: BorderOptions) => void;
onClose: () => void;
anchorEl: React.RefObject<HTMLElement>; anchorEl: React.RefObject<HTMLElement>;
anchorOrigin?: PopoverOrigin; anchorOrigin?: PopoverOrigin;
transformOrigin?: PopoverOrigin; transformOrigin?: PopoverOrigin;
@@ -36,25 +37,33 @@ type BorderPickerProps = {
const BorderPicker = (properties: BorderPickerProps) => { const BorderPicker = (properties: BorderPickerProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [borderSelected, setBorderSelected] = useState(BorderType.None); const [borderSelected, setBorderSelected] = useState<BorderType | null>(null);
const [borderColor, setBorderColor] = useState("#000000"); const [borderColor, setBorderColor] = useState("#000000");
const [borderStyle, setBorderStyle] = useState(BorderStyle.Thin); const [borderStyle, setBorderStyle] = useState(BorderStyle.Thin);
const [colorPickerOpen, setColorPickerOpen] = useState(false); const [colorPickerOpen, setColorPickerOpen] = useState(false);
const [stylePickerOpen, setStylePickerOpen] = useState(false); const [stylePickerOpen, setStylePickerOpen] = useState(false);
const closePicker = (): void => {
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
if (!borderSelected) {
return;
}
properties.onChange({ properties.onChange({
color: borderColor, color: borderColor,
style: borderStyle, style: borderStyle,
border: borderSelected, border: borderSelected,
}); });
}; }, [borderColor, borderStyle, borderSelected]);
const onClose = properties.onClose;
const borderColorButton = useRef(null); const borderColorButton = useRef(null);
const borderStyleButton = useRef(null); const borderStyleButton = useRef(null);
return ( return (
<> <>
<StyledPopover <StyledPopover
open={properties.open} open={properties.open}
onClose={(): void => closePicker()} onClose={onClose}
anchorEl={properties.anchorEl.current} anchorEl={properties.anchorEl.current}
anchorOrigin={ anchorOrigin={
properties.anchorOrigin || { vertical: "bottom", horizontal: "left" } properties.anchorOrigin || { vertical: "bottom", horizontal: "left" }
@@ -71,13 +80,13 @@ const BorderPicker = (properties: BorderPickerProps) => {
$pressed={borderSelected === BorderType.All} $pressed={borderSelected === BorderType.All}
onClick={() => { onClick={() => {
if (borderSelected === BorderType.All) { if (borderSelected === BorderType.All) {
setBorderSelected(BorderType.None); setBorderSelected(null);
} else { } else {
setBorderSelected(BorderType.All); setBorderSelected(BorderType.All);
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.all")}
> >
<BorderAllIcon /> <BorderAllIcon />
</Button> </Button>
@@ -86,13 +95,13 @@ const BorderPicker = (properties: BorderPickerProps) => {
$pressed={borderSelected === BorderType.Inner} $pressed={borderSelected === BorderType.Inner}
onClick={() => { onClick={() => {
if (borderSelected === BorderType.Inner) { if (borderSelected === BorderType.Inner) {
setBorderSelected(BorderType.None); setBorderSelected(null);
} else { } else {
setBorderSelected(BorderType.Inner); setBorderSelected(BorderType.Inner);
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.inner")}
> >
<BorderInnerIcon /> <BorderInnerIcon />
</Button> </Button>
@@ -101,13 +110,13 @@ const BorderPicker = (properties: BorderPickerProps) => {
$pressed={borderSelected === BorderType.CenterH} $pressed={borderSelected === BorderType.CenterH}
onClick={() => { onClick={() => {
if (borderSelected === BorderType.CenterH) { if (borderSelected === BorderType.CenterH) {
setBorderSelected(BorderType.None); setBorderSelected(null);
} else { } else {
setBorderSelected(BorderType.CenterH); setBorderSelected(BorderType.CenterH);
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.horizontal")}
> >
<BorderCenterHIcon /> <BorderCenterHIcon />
</Button> </Button>
@@ -116,13 +125,13 @@ const BorderPicker = (properties: BorderPickerProps) => {
$pressed={borderSelected === BorderType.CenterV} $pressed={borderSelected === BorderType.CenterV}
onClick={() => { onClick={() => {
if (borderSelected === BorderType.CenterV) { if (borderSelected === BorderType.CenterV) {
setBorderSelected(BorderType.None); setBorderSelected(null);
} else { } else {
setBorderSelected(BorderType.CenterV); setBorderSelected(BorderType.CenterV);
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.vertical")}
> >
<BorderCenterVIcon /> <BorderCenterVIcon />
</Button> </Button>
@@ -137,7 +146,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.outer")}
> >
<BorderOuterIcon /> <BorderOuterIcon />
</Button> </Button>
@@ -154,7 +163,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.clear")}
> >
<BorderNoneIcon /> <BorderNoneIcon />
</Button> </Button>
@@ -169,7 +178,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.top")}
> >
<BorderTopIcon /> <BorderTopIcon />
</Button> </Button>
@@ -184,7 +193,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.right")}
> >
<BorderRightIcon /> <BorderRightIcon />
</Button> </Button>
@@ -199,7 +208,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.bottom")}
> >
<BorderBottomIcon /> <BorderBottomIcon />
</Button> </Button>
@@ -214,7 +223,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
} }
}} }}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.left")}
> >
<BorderLeftIcon /> <BorderLeftIcon />
</Button> </Button>
@@ -228,7 +237,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
$pressed={false} $pressed={false}
disabled={false} disabled={false}
ref={borderColorButton} ref={borderColorButton}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.color")}
> >
<PencilLine /> <PencilLine />
</Button> </Button>
@@ -243,7 +252,7 @@ const BorderPicker = (properties: BorderPickerProps) => {
type="button" type="button"
$pressed={false} $pressed={false}
disabled={false} disabled={false}
title={t("workbook.toolbar.borders_button_title")} title={t("toolbar.borders.style")}
> >
<BorderStyleIcon /> <BorderStyleIcon />
</Button> </Button>
@@ -371,22 +380,6 @@ const LineWrapper = styled("div")<LineWrapperProperties>`
border: 1px solid white; border: 1px solid white;
`; `;
const CheckIconWrapper = styled("div")`
width: 12px;
`;
type CheckIconProperties = { $checked: boolean };
const CheckIcon = styled("div")<CheckIconProperties>`
width: 2px;
background-color: #eee;
height: 28px;
visibility: ${({ $checked }): string => {
if ($checked) {
return "visible";
}
return "hidden";
}};
`;
const NoneLine = styled("div")` const NoneLine = styled("div")`
width: 68px; width: 68px;
border-top: 1px solid #e0e0e0; border-top: 1px solid #e0e0e0;
@@ -474,7 +467,7 @@ const StyledPopover = styled(Popover)`
padding: 0px; padding: 0px;
} }
font-family: ${({ theme }) => theme.typography.fontFamily}; font-family: ${({ theme }) => theme.typography.fontFamily};
font-size: 13px; font-size: 12px;
`; `;
const BorderPickerDialog = styled("div")` const BorderPickerDialog = styled("div")`
@@ -499,7 +492,7 @@ const Button = styled("button")<TypeButtonProperties>(
justifyContent: "center", justifyContent: "center",
// fontSize: "26px", // fontSize: "26px",
border: "0px solid #fff", border: "0px solid #fff",
borderRadius: "2px", borderRadius: "4px",
marginRight: "5px", marginRight: "5px",
transition: "all 0.2s", transition: "all 0.2s",
cursor: "pointer", cursor: "pointer",
@@ -517,7 +510,7 @@ const Button = styled("button")<TypeButtonProperties>(
borderTop: $underlinedColor ? "3px solid #FFF" : "none", borderTop: $underlinedColor ? "3px solid #FFF" : "none",
borderBottom: $underlinedColor ? `3px solid ${$underlinedColor}` : "none", borderBottom: $underlinedColor ? `3px solid ${$underlinedColor}` : "none",
color: "#21243A", color: "#21243A",
backgroundColor: $pressed ? theme.palette.grey["600"] : "inherit", backgroundColor: $pressed ? theme.palette.grey["200"] : "inherit",
"&:hover": { "&:hover": {
backgroundColor: "#F1F2F8", backgroundColor: "#F1F2F8",
borderTopColor: "#F1F2F8", borderTopColor: "#F1F2F8",

View File

@@ -0,0 +1,2 @@
export const getNewClipboardId = () => new Date().toISOString();
export const CLIPBOARD_ID_SESSION_STORAGE_KEY = "IronCalc-Clipboard";

View File

@@ -0,0 +1,3 @@
export const TOOLBAR_HEIGH = 48;
export const FORMULA_BAR_HEIGH = 40;
export const NAVIGATION_HEIGH = 40;

View File

@@ -0,0 +1,255 @@
// This is the cell editor for IronCalc
// It is also the single most difficult part of the UX. It is based on an idea of the
// celebrated Polish developer Mateusz Kopec.
// There is a hidden texarea and we only show the caret. What we see is a div with the same text content
// but in HTML so we can have different colors.
// Some keystrokes have different behaviour than a raw HTML text area.
// For those cases we capture the keydown event and stop its propagation.
// As the editor changes content we need to propagate those changes so the spreadsheet can
// mark with colors the active ranges or update the formula in the formula bar
//
// Events outside the editor might influence the editor
// 1. Clicking on a different cell:
// * might either terminate the editing
// * or add the external cell to the formula
// 2. Clicking on a sheet tab would open the new sheet or terminate editing
// 3. Clicking somewhere else will finish editing
//
// Keyboard navigation is also fairly complex. For instance RightArrow might:
// 1. End editing and navigate to the cell on the right
// 2. Move the cursor to the right
// 3. Insert in the formula the cell name on the right
// You can either be editing a formula or content.
// When editing content (behaviour is common to Excel and Google Sheets):
// * If you start editing by typing you are in *accept* mode
// * If you start editing by F2 you are in *cruise* mode
// * If you start editing by double click you are in *cruise* mode
// In Google Sheets "Enter" starts editing and puts you in *cruise* mode. We do not do that
// Once you are in cruise mode it is not possible to switch to accept mode
// The only way to go from accept mode to cruise mode is clicking in the content somewhere
// When editing a formula.
// In Google Sheets you are either in insert mode or cruise mode.
// You can get back to accept mode if you delete the whole formula
// In Excel you can be either in insert or accept but if you click in the formula body
// you switch to cruise mode. Once in cruise mode you can go to insert mode by selecting a range.
// Then you are back in accept/insert modes
import type { Model } from "@ironcalc/wasm";
import {
type CSSProperties,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { WorkbookState } from "../workbookState";
import useKeyDown from "./useKeyDown";
import getFormulaHTML from "./util";
const commonCSS: CSSProperties = {
fontWeight: "inherit",
fontFamily: "inherit",
fontSize: "inherit",
position: "absolute",
left: 0,
top: 0,
whiteSpace: "pre",
width: "100%",
padding: 0,
lineHeight: "22px",
};
const caretColor = "rgb(242, 153, 74)";
interface EditorOptions {
originalText: string;
onEditEnd: () => void;
onTextUpdated: () => void;
model: Model;
workbookState: WorkbookState;
type: "cell" | "formula-bar";
}
const Editor = (options: EditorOptions) => {
const { model, onEditEnd, onTextUpdated, originalText, workbookState, type } =
options;
const [text, setText] = useState(originalText);
const formulaRef = useRef<HTMLDivElement>(null);
const maskRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setText(originalText);
if (textareaRef.current) {
textareaRef.current.value = originalText;
}
}, [originalText]);
const { onKeyDown } = useKeyDown({
model,
onEditEnd,
onTextUpdated,
workbookState,
textareaRef,
});
useEffect(() => {
const cell = workbookState.getEditingCell();
if (!cell) {
return;
}
const { editorWidth, editorHeight } = cell;
if (formulaRef.current) {
const scrollWidth = formulaRef.current.scrollWidth;
if (scrollWidth > editorWidth - 5) {
cell.editorWidth = scrollWidth + 10;
}
const scrollHeight = formulaRef.current.scrollHeight;
if (scrollHeight > editorHeight) {
cell.editorHeight = scrollHeight;
}
}
if (type === cell.focus) {
textareaRef.current?.focus();
}
});
const onChange = useCallback(() => {
const textarea = textareaRef.current;
const cell = workbookState.getEditingCell();
if (!textarea || !cell) {
return;
}
const value = textarea.value;
cell.text = value;
cell.referencedRange = null;
cell.cursorStart = textarea.selectionStart;
cell.cursorEnd = textarea.selectionEnd;
const styledFormula = getFormulaHTML(model, value);
if (value === "" && type === "cell") {
// When we delete the content of a cell we jump to accept mode
cell.mode = "accept";
}
workbookState.setEditingCell(cell);
workbookState.setActiveRanges(styledFormula.activeRanges);
setText(cell.text);
onTextUpdated();
// Should we stop propagations?
// event.stopPropagation();
// event.preventDefault();
}, [workbookState, model, onTextUpdated, type]);
const onBlur = useCallback(() => {
const cell = workbookState.getEditingCell();
if (type !== cell?.focus) {
// If the onBlur event is called because we switch from the cell editor to the formula editor
// or vice versa, do nothing
return;
}
if (textareaRef.current) {
textareaRef.current.value = "";
}
// This happens if the blur hasn't been taken care before by
// onclick or onpointerdown events
// If we are editing a cell finish that
if (cell) {
model.setUserInput(
cell.sheet,
cell.row,
cell.column,
workbookState.getEditingText(),
);
workbookState.clearEditingCell();
}
onEditEnd();
}, [model, workbookState, onEditEnd, type]);
const cell = workbookState.getEditingCell();
const showEditor = cell !== null || type === "formula-bar" ? "block" : "none";
const mtext = cell ? workbookState.getEditingText() : originalText;
const styledFormula = getFormulaHTML(model, mtext).html;
return (
<div
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
display: showEditor,
background: "#FFF",
fontFamily: "Inter",
fontSize: "13px",
}}
>
<div
ref={maskRef}
style={{
...commonCSS,
textAlign: "left",
pointerEvents: "none",
height: "100%",
}}
>
<div
style={{
display: "inline-block",
}}
ref={formulaRef}
>
{styledFormula}
</div>
</div>
<textarea
ref={textareaRef}
rows={1}
style={{
...commonCSS,
color: "transparent",
backgroundColor: "transparent",
caretColor,
outline: "none",
resize: "none",
border: "none",
height: "100%",
overflow: "hidden",
alignContent: "baseline",
}}
defaultValue={text}
spellCheck="false"
onKeyDown={onKeyDown}
onChange={onChange}
onBlur={onBlur}
onPointerDown={(event) => {
// We are either clicking in the same cell we are editing,
// in which case we just change the mode to edit, or we click
// in a different editor, in which case we switch the focus
const cell = workbookState.getEditingCell();
if (cell) {
// We make sure the mode is edit
cell.mode = "edit";
cell.focus = type;
workbookState.setEditingCell(cell);
event.stopPropagation();
}
}}
onScroll={() => {
if (maskRef.current && textareaRef.current) {
maskRef.current.style.left = `-${textareaRef.current.scrollLeft}px`;
maskRef.current.style.top = `-${textareaRef.current.scrollTop}px`;
}
}}
/>
</div>
);
};
export default Editor;

View File

@@ -0,0 +1,401 @@
import type { Model } from "@ironcalc/wasm";
import { type KeyboardEvent, type RefObject, useCallback } from "react";
import { rangeToStr } from "../util";
import type { WorkbookState } from "../workbookState";
import { isInReferenceMode } from "./util";
interface Options {
model: Model;
onEditEnd: () => void;
onTextUpdated: () => void;
workbookState: WorkbookState;
textareaRef: RefObject<HTMLTextAreaElement>;
}
export const useKeyDown = (
options: Options,
): { onKeyDown: (event: KeyboardEvent) => void } => {
const { model, onEditEnd, onTextUpdated, workbookState, textareaRef } =
options;
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
const { key, shiftKey, altKey } = event;
const textarea = textareaRef.current;
const cell = workbookState.getEditingCell();
if (!textarea || !cell) {
return;
}
switch (key) {
case "Enter": {
if (altKey) {
// new line
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
const newText = `${value.slice(0, start)}\n${value.slice(end)}`;
cell.text = newText;
workbookState.setEditingCell(cell);
setTimeout(() => {
textarea.setSelectionRange(start + 1, start + 1);
}, 0);
event.stopPropagation();
event.preventDefault();
onTextUpdated();
return;
}
event.stopPropagation();
event.preventDefault();
// end edit and select cell bellow (or above if shiftKey)
model.setUserInput(
cell.sheet,
cell.row,
cell.column,
cell.text + (cell.referencedRange?.str || ""),
);
const sign = shiftKey ? -1 : 1;
model.setSelectedSheet(cell.sheet);
model.setSelectedCell(cell.row + sign, cell.column);
workbookState.clearEditingCell();
onEditEnd();
return;
}
case "Tab": {
// end edit and select cell to the right (or left if ShiftKey)
workbookState.clearEditingCell();
model.setUserInput(
cell.sheet,
cell.row,
cell.column,
cell.text + (cell.referencedRange?.str || ""),
);
const sign = shiftKey ? -1 : 1;
model.setSelectedSheet(cell.sheet);
model.setSelectedCell(cell.row, cell.column + sign);
if (textareaRef.current) {
textareaRef.current.value = "";
}
event.stopPropagation();
event.preventDefault();
onEditEnd();
return;
}
case "Escape": {
// quit editing without modifying the cell
const cell = workbookState.getEditingCell();
if (cell) {
model.setSelectedSheet(cell.sheet);
}
workbookState.clearEditingCell();
onEditEnd();
return;
}
// TODO: Arrow keys navigate in Excel
case "ArrowRight": {
if (cell.mode === "edit") {
// just edit
return;
}
event.stopPropagation();
event.preventDefault();
if (cell.referencedRange) {
// There is already a reference range we move it to the right
// (or expand if shift is pressed)
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const range = cell.referencedRange.range;
if (shiftKey) {
range.columnEnd += 1;
} else {
const column = range.columnStart + 1;
const row = range.rowStart;
range.columnStart = column;
range.columnEnd = column;
range.rowEnd = row;
}
cell.referencedRange = {
range,
str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]),
};
workbookState.setEditingCell(cell);
onTextUpdated();
return;
}
if (isInReferenceMode(cell.text, cell.cursorStart)) {
// there is not a referenced Range but we are in reference mode
// we select the next cell
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const range = {
sheet: cell.sheet,
rowStart: cell.row,
rowEnd: cell.row,
columnStart: cell.column + 1,
columnEnd: cell.column + 1,
};
cell.referencedRange = {
range,
str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]),
};
workbookState.setEditingCell(cell);
onTextUpdated();
return;
}
// at this point we finish editing and select the cell to the right
// (or left if ShiftKey is pressed)
workbookState.clearEditingCell();
model.setUserInput(cell.sheet, cell.row, cell.column, cell.text);
model.setSelectedSheet(cell.sheet);
if (shiftKey) {
// TODO: ShiftKey
} else {
model.setSelectedCell(cell.row, cell.column + 1);
}
if (textareaRef.current) {
textareaRef.current.value = "";
}
onEditEnd();
return;
}
case "ArrowLeft": {
if (cell.mode === "edit") {
return;
}
event.stopPropagation();
event.preventDefault();
if (cell.referencedRange) {
// There is already a reference range we move it to the right
// (or expand if shift is pressed)
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const range = cell.referencedRange.range;
if (shiftKey) {
range.columnEnd -= 1;
} else {
const column = range.columnStart - 1;
const row = range.rowStart;
range.columnStart = column;
range.columnEnd = column;
range.rowEnd = row;
}
cell.referencedRange = {
range,
str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]),
};
workbookState.setEditingCell(cell);
onTextUpdated();
return;
}
if (isInReferenceMode(cell.text, cell.cursorStart)) {
// there is not a referenced Range but we are in reference mode
// we select the next cell
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const range = {
sheet: cell.sheet,
rowStart: cell.row,
rowEnd: cell.row,
columnStart: cell.column - 1,
columnEnd: cell.column - 1,
};
cell.referencedRange = {
range,
str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]),
};
workbookState.setEditingCell(cell);
onTextUpdated();
return;
}
// at this point we finish editing and select the cell to the right
// (or left if ShiftKey is pressed)
workbookState.clearEditingCell();
model.setUserInput(cell.sheet, cell.row, cell.column, cell.text);
model.setSelectedSheet(cell.sheet);
if (shiftKey) {
// TODO: ShiftKey
} else {
model.setSelectedCell(cell.row, cell.column - 1);
}
if (textareaRef.current) {
textareaRef.current.value = "";
}
onEditEnd();
return;
}
case "ArrowUp": {
if (cell.mode === "edit") {
return;
}
event.stopPropagation();
event.preventDefault();
if (cell.referencedRange) {
// There is already a reference range we move it to the right
// (or expand if shift is pressed)
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const range = cell.referencedRange.range;
if (shiftKey) {
if (range.rowEnd > range.rowStart) {
range.rowEnd -= 1;
} else {
range.rowStart -= 1;
}
} else {
const column = range.columnStart;
const row = range.rowStart - 1;
range.columnStart = column;
range.columnEnd = column;
range.rowStart = row;
range.rowEnd = row;
}
cell.referencedRange = {
range,
str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]),
};
workbookState.setEditingCell(cell);
onTextUpdated();
return;
}
if (isInReferenceMode(cell.text, cell.cursorStart)) {
// there is not a referenced Range but we are in reference mode
// we select the next cell
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const range = {
sheet: cell.sheet,
rowStart: cell.row - 1,
rowEnd: cell.row - 1,
columnStart: cell.column,
columnEnd: cell.column,
};
cell.referencedRange = {
range,
str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]),
};
workbookState.setEditingCell(cell);
onTextUpdated();
return;
}
// at this point we finish editing and select the cell to the right
// (or left if ShiftKey is pressed)
workbookState.clearEditingCell();
model.setUserInput(cell.sheet, cell.row, cell.column, cell.text);
model.setSelectedSheet(cell.sheet);
if (shiftKey) {
// TODO: ShiftKey
} else {
model.setSelectedCell(cell.row - 1, cell.column);
}
if (textareaRef.current) {
textareaRef.current.value = "";
}
onEditEnd();
return;
}
case "ArrowDown": {
if (cell.mode === "edit") {
return;
}
event.stopPropagation();
event.preventDefault();
if (cell.referencedRange) {
// There is already a reference range we move it to the right
// (or expand if shift is pressed)
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const range = cell.referencedRange.range;
if (shiftKey) {
range.rowEnd += 1;
} else {
const column = range.columnStart;
const row = range.rowStart + 1;
range.columnStart = column;
range.columnEnd = column;
range.rowStart = row;
range.rowEnd = row;
}
cell.referencedRange = {
range,
str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]),
};
workbookState.setEditingCell(cell);
onTextUpdated();
return;
}
if (isInReferenceMode(cell.text, cell.cursorStart)) {
// there is not a referenced Range but we are in reference mode
// we select the next cell
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
const range = {
sheet: cell.sheet,
rowStart: cell.row + 1,
rowEnd: cell.row + 1,
columnStart: cell.column,
columnEnd: cell.column,
};
cell.referencedRange = {
range,
str: rangeToStr(range, cell.sheet, sheetNames[range.sheet]),
};
workbookState.setEditingCell(cell);
onTextUpdated();
return;
}
// at this point we finish editing and select the cell to the right
// (or left if ShiftKey is pressed)
workbookState.clearEditingCell();
model.setUserInput(cell.sheet, cell.row, cell.column, cell.text);
model.setSelectedSheet(cell.sheet);
if (shiftKey) {
// TODO: ShiftKey
} else {
model.setSelectedCell(cell.row + 1, cell.column);
}
if (textareaRef.current) {
textareaRef.current.value = "";
}
onEditEnd();
return;
}
case "Shift": {
return;
}
case "PageDown":
case "PageUp": {
// TODO: We can do something similar to what we do with navigation keys
event.stopPropagation();
event.preventDefault();
return;
}
case "End":
case "Home": {
// Excel does something similar to what we do with navigation keys
cell.mode = "edit";
workbookState.setEditingCell(cell);
return;
}
default: {
// noop
}
}
},
[model, onEditEnd, onTextUpdated, workbookState, textareaRef.current],
);
return { onKeyDown };
};
export default useKeyDown;

View File

@@ -0,0 +1,189 @@
import {
type Model,
type Range,
type Reference,
type TokenType,
getTokens,
} from "@ironcalc/wasm";
import type { ActiveRange } from "../workbookState";
export function tokenIsReferenceType(token: TokenType): token is Reference {
return typeof token === "object" && "Reference" in token;
}
export function tokenIsRangeType(token: TokenType): token is Range {
return typeof token === "object" && "Range" in token;
}
export function isInReferenceMode(text: string, cursor: number): boolean {
// FIXME
// This is a gross oversimplification
// Returns true if both are true:
// 1. Cursor is at the end
// 2. Last char is one of [',', '(', '+', '*', '-', '/', '<', '>', '=', '&']
// This has many false positives like '="1+' and also likely some false negatives
// The right way of doing this is to have a partial parse of the formula tree
// and check if the next token could be a reference
if (!text.startsWith("=")) {
return false;
}
if (text === "=") {
return true;
}
const l = text.length;
const chars = [",", "(", "+", "*", "-", "/", "<", ">", "=", "&"];
if (cursor === l && chars.includes(text[l - 1])) {
return true;
}
return false;
}
// IronCalc Color Palette
export function getColor(index: number, alpha = 1): string {
const colors = [
{
name: "Cyan",
rgba: [89, 185, 188, 1],
hex: "#59B9BC",
},
{
name: "Flamingo",
rgba: [236, 87, 83, 1],
hex: "#EC5753",
},
{
hex: "#3358B7",
rgba: [51, 88, 183, 1],
name: "Blue",
},
{
hex: "#F8CD3C",
rgba: [248, 205, 60, 1],
name: "Yellow",
},
{
hex: "#3BB68A",
rgba: [59, 182, 138, 1],
name: "Emerald",
},
{
hex: "#523E93",
rgba: [82, 62, 147, 1],
name: "Violet",
},
{
hex: "#A23C52",
rgba: [162, 60, 82, 1],
name: "Burgundy",
},
{
hex: "#8CB354",
rgba: [162, 60, 82, 1],
name: "Wasabi",
},
{
hex: "#D03627",
rgba: [208, 54, 39, 1],
name: "Red",
},
{
hex: "#1B717E",
rgba: [27, 113, 126, 1],
name: "Teal",
},
];
if (alpha === 1) {
return colors[index % 10].hex;
}
const { rgba } = colors[index % 10];
return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${alpha})`;
}
function getFormulaHTML(
model: Model,
text: string,
): { html: JSX.Element[]; activeRanges: ActiveRange[] } {
let html: JSX.Element[] = [];
const activeRanges: ActiveRange[] = [];
let colorCount = 0;
if (text.startsWith("=")) {
const formula = text.slice(1);
const tokens = getTokens(formula);
const tokenCount = tokens.length;
const usedColors: Record<string, string> = {};
const sheet = model.getSelectedSheet();
const sheetList = model.getWorksheetsProperties().map((s) => s.name);
for (let index = 0; index < tokenCount; index += 1) {
const { token, start, end } = tokens[index];
if (tokenIsReferenceType(token)) {
const { sheet: refSheet, row, column } = token.Reference;
const sheetIndex = refSheet ? sheetList.indexOf(refSheet) : sheet;
const key = `${sheetIndex}-${row}-${column}`;
let color = usedColors[key];
if (!color) {
color = getColor(colorCount);
usedColors[key] = color;
colorCount += 1;
}
html.push(
<span key={index} style={{ color }}>
{formula.slice(start, end)}
</span>,
);
activeRanges.push({
sheet: sheetIndex,
rowStart: row,
columnStart: column,
rowEnd: row,
columnEnd: column,
color,
});
} else if (tokenIsRangeType(token)) {
let {
sheet: refSheet,
left: { row: rowStart, column: columnStart },
right: { row: rowEnd, column: columnEnd },
} = token.Range;
const sheetIndex = refSheet ? sheetList.indexOf(refSheet) : sheet;
const key = `${sheetIndex}-${rowStart}-${columnStart}:${rowEnd}-${columnEnd}`;
let color = usedColors[key];
if (!color) {
color = getColor(colorCount);
usedColors[key] = color;
colorCount += 1;
}
if (rowStart > rowEnd) {
[rowStart, rowEnd] = [rowEnd, rowStart];
}
if (columnStart > columnEnd) {
[columnStart, columnEnd] = [columnEnd, columnStart];
}
html.push(
<span key={index} style={{ color }}>
{formula.slice(start, end)}
</span>,
);
colorCount += 1;
activeRanges.push({
sheet: sheetIndex,
rowStart,
columnStart,
rowEnd,
columnEnd,
color,
});
} else {
html.push(<span key={index}>{formula.slice(start, end)}</span>);
}
}
html = [<span key="equals">=</span>].concat(html);
} else {
html = [<span key="single">{text}</span>];
}
return { html, activeRanges };
}
export default getFormulaHTML;

View File

@@ -1,5 +1,5 @@
import { Menu, MenuItem, styled } from "@mui/material"; import { Menu, MenuItem, styled } from "@mui/material";
import { type ComponentProps, 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";
@@ -14,11 +14,18 @@ type FormatMenuProps = {
const FormatMenu = (properties: FormatMenuProps) => { const FormatMenu = (properties: FormatMenuProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { onChange } = properties;
const [isMenuOpen, setMenuOpen] = useState(false); const [isMenuOpen, setMenuOpen] = useState(false);
const [isPickerOpen, setPickerOpen] = useState(false); const [isPickerOpen, setPickerOpen] = useState(false);
const anchorElement = useRef<HTMLDivElement>(null); const anchorElement = useRef<HTMLDivElement>(null);
const onSelect = useCallback(
(s: string) => {
properties.onChange(s);
setMenuOpen(false);
},
[properties.onChange],
);
return ( return (
<> <>
<ChildrenWrapper <ChildrenWrapper
@@ -33,18 +40,18 @@ const FormatMenu = (properties: FormatMenuProps) => {
anchorEl={anchorElement.current} anchorEl={anchorElement.current}
anchorOrigin={properties.anchorOrigin} anchorOrigin={properties.anchorOrigin}
> >
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.AUTO)}> <MenuItemWrapper onClick={(): void => onSelect(NumberFormats.AUTO)}>
<MenuItemText>{t("toolbar.format_menu.auto")}</MenuItemText> <MenuItemText>{t("toolbar.format_menu.auto")}</MenuItemText>
</MenuItemWrapper> </MenuItemWrapper>
<MenuDivider /> <MenuDivider />
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.NUMBER)}> <MenuItemWrapper onClick={(): void => onSelect(NumberFormats.NUMBER)}>
<MenuItemText>{t("toolbar.format_menu.number")}</MenuItemText> <MenuItemText>{t("toolbar.format_menu.number")}</MenuItemText>
<MenuItemExample> <MenuItemExample>
{t("toolbar.format_menu.number_example")} {t("toolbar.format_menu.number_example")}
</MenuItemExample> </MenuItemExample>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onChange(NumberFormats.PERCENTAGE)} onClick={(): void => onSelect(NumberFormats.PERCENTAGE)}
> >
<MenuItemText>{t("toolbar.format_menu.percentage")}</MenuItemText> <MenuItemText>{t("toolbar.format_menu.percentage")}</MenuItemText>
<MenuItemExample> <MenuItemExample>
@@ -54,7 +61,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuDivider /> <MenuDivider />
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onChange(NumberFormats.CURRENCY_EUR)} onClick={(): void => onSelect(NumberFormats.CURRENCY_EUR)}
> >
<MenuItemText>{t("toolbar.format_menu.currency_eur")}</MenuItemText> <MenuItemText>{t("toolbar.format_menu.currency_eur")}</MenuItemText>
<MenuItemExample> <MenuItemExample>
@@ -62,7 +69,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
</MenuItemExample> </MenuItemExample>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onChange(NumberFormats.CURRENCY_USD)} onClick={(): void => onSelect(NumberFormats.CURRENCY_USD)}
> >
<MenuItemText>{t("toolbar.format_menu.currency_usd")}</MenuItemText> <MenuItemText>{t("toolbar.format_menu.currency_usd")}</MenuItemText>
<MenuItemExample> <MenuItemExample>
@@ -70,7 +77,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
</MenuItemExample> </MenuItemExample>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onChange(NumberFormats.CURRENCY_GBP)} onClick={(): void => onSelect(NumberFormats.CURRENCY_GBP)}
> >
<MenuItemText>{t("toolbar.format_menu.currency_gbp")}</MenuItemText> <MenuItemText>{t("toolbar.format_menu.currency_gbp")}</MenuItemText>
<MenuItemExample> <MenuItemExample>
@@ -80,7 +87,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
<MenuDivider /> <MenuDivider />
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onChange(NumberFormats.DATE_SHORT)} onClick={(): void => onSelect(NumberFormats.DATE_SHORT)}
> >
<MenuItemText>{t("toolbar.format_menu.date_short")}</MenuItemText> <MenuItemText>{t("toolbar.format_menu.date_short")}</MenuItemText>
<MenuItemExample> <MenuItemExample>
@@ -88,7 +95,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
</MenuItemExample> </MenuItemExample>
</MenuItemWrapper> </MenuItemWrapper>
<MenuItemWrapper <MenuItemWrapper
onClick={(): void => onChange(NumberFormats.DATE_LONG)} onClick={(): void => onSelect(NumberFormats.DATE_LONG)}
> >
<MenuItemText>{t("toolbar.format_menu.date_long")}</MenuItemText> <MenuItemText>{t("toolbar.format_menu.date_long")}</MenuItemText>
<MenuItemExample> <MenuItemExample>
@@ -103,7 +110,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
</Menu> </Menu>
<FormatPicker <FormatPicker
numFmt={properties.numFmt} numFmt={properties.numFmt}
onChange={properties.onChange} onChange={onSelect}
open={isPickerOpen} open={isPickerOpen}
onClose={(): void => setPickerOpen(false)} onClose={(): void => setPickerOpen(false)}
onExited={properties.onExited} onExited={properties.onExited}
@@ -115,7 +122,7 @@ const FormatMenu = (properties: FormatMenuProps) => {
const MenuItemWrapper = styled(MenuItem)` const MenuItemWrapper = styled(MenuItem)`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 14px; font-size: 12px;
width: 100%; width: 100%;
`; `;
@@ -130,6 +137,7 @@ const MenuItemText = styled("div")`
`; `;
const MenuItemExample = styled("div")` const MenuItemExample = styled("div")`
color: #bdbdbd;
margin-left: 20px; margin-left: 20px;
`; `;

View File

@@ -35,6 +35,11 @@ const FormatPicker = (properties: FormatPickerProps) => {
label={t("num_fmt.label")} label={t("num_fmt.label")}
name="format_code" name="format_code"
onChange={(event) => setFormatCode(event.target.value)} onChange={(event) => setFormatCode(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
}}
spellCheck="false"
onClick={(event) => event.stopPropagation()}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View File

@@ -1,6 +1,12 @@
// FIXME: These two should be done in the back end and thoroughly tested
// * Dates shouldn't change
// * General depends on the value. Increase(General, 0.5) => 0.50 and so on
export function increaseDecimalPlaces(numberFormat: string): string { export function increaseDecimalPlaces(numberFormat: string): string {
// FIXME: Should it be done in the Rust? How should it work?
// Increase decimal places for existing numbers with decimals // Increase decimal places for existing numbers with decimals
if (numberFormat === "general") {
return "#,##0.000";
}
const newNumberFormat = numberFormat.replace(/\.0/g, ".00"); const newNumberFormat = numberFormat.replace(/\.0/g, ".00");
// If no decimal places declared, add 0.0 // If no decimal places declared, add 0.0
if (!newNumberFormat.includes(".")) { if (!newNumberFormat.includes(".")) {
@@ -10,13 +16,15 @@ export function increaseDecimalPlaces(numberFormat: string): string {
if (newNumberFormat.includes("#")) { if (newNumberFormat.includes("#")) {
return newNumberFormat.replace(/#([^#,]|$)/g, "0.0$1"); return newNumberFormat.replace(/#([^#,]|$)/g, "0.0$1");
} }
return "0.0"; return numberFormat;
} }
return newNumberFormat; return newNumberFormat;
} }
export function decreaseDecimalPlaces(numberFormat: string): string { export function decreaseDecimalPlaces(numberFormat: string): string {
// FIXME: Should it be done in the Rust? How should it work? if (numberFormat === "general") {
return "#,##0.0";
}
// Decrease decimal places for existing numbers with decimals // Decrease decimal places for existing numbers with decimals
let newNumberFormat = numberFormat.replace(/\.0/g, "."); let newNumberFormat = numberFormat.replace(/\.0/g, ".");
// Fix leftover dots // Fix leftover dots

View File

@@ -1,50 +0,0 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface FormulaDialogProps {
isOpen: boolean;
close: () => void;
onFormulaChanged: (name: string) => void;
defaultFormula: string;
}
export const FormulaDialog = (properties: FormulaDialogProps) => {
const { t } = useTranslation();
const [formula, setFormula] = useState(properties.defaultFormula);
return (
<Dialog open={properties.isOpen} onClose={properties.close}>
<DialogTitle>{t("formula_input.title")}</DialogTitle>
<DialogContent dividers>
<TextField
defaultValue={formula}
label={t("formula_input.label")}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
}}
onChange={(event) => {
setFormula(event.target.value);
}}
spellCheck="false"
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
properties.onFormulaChanged(formula);
}}
>
{t("formula_input.update")}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,59 +1,88 @@
import { Button, styled } from "@mui/material"; import type { Model } from "@ironcalc/wasm";
import { ChevronDown } from "lucide-react"; import { styled } from "@mui/material";
import { useState } from "react";
import { Fx } from "../icons"; import { Fx } from "../icons";
import { FormulaDialog } from "./formulaDialog"; import {
COLUMN_WIDTH_SCALE,
ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants";
import { FORMULA_BAR_HEIGH } from "./constants";
import Editor from "./editor/editor";
import type { WorkbookState } from "./workbookState";
type FormulaBarProps = { type FormulaBarProps = {
cellAddress: string; cellAddress: string;
formulaValue: string; formulaValue: string;
onChange: (value: string) => void; model: Model;
workbookState: WorkbookState;
onChange: () => void;
onTextUpdated: () => void;
}; };
const formulaBarHeight = 30; const headerColumnWidth = 35;
const headerColumnWidth = 30;
function FormulaBar(properties: FormulaBarProps) { function FormulaBar(properties: FormulaBarProps) {
const [formulaDialogOpen, setFormulaDialogOpen] = useState(false); const {
const handleCloseFormulaDialog = () => { cellAddress,
setFormulaDialogOpen(false); formulaValue,
}; model,
onChange,
onTextUpdated,
workbookState,
} = properties;
return ( return (
<Container> <Container>
<AddressContainer> <AddressContainer>
<CellBarAddress>{properties.cellAddress}</CellBarAddress> <CellBarAddress>{cellAddress}</CellBarAddress>
<StyledButton>
<ChevronDown />
</StyledButton>
</AddressContainer> </AddressContainer>
<Divider /> <Divider />
<FormulaContainer> <FormulaContainer>
<FormulaSymbolButton> <FormulaSymbolButton>
<Fx /> <Fx />
</FormulaSymbolButton> </FormulaSymbolButton>
<Editor <EditorWrapper
onClick={() => { onClick={(event) => {
setFormulaDialogOpen(true); const [sheet, row, column] = model.getSelectedCell();
const editorWidth =
model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE;
const editorHeight =
model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE;
workbookState.setEditingCell({
sheet,
row,
column,
text: formulaValue,
referencedRange: null,
cursorStart: formulaValue.length,
cursorEnd: formulaValue.length,
focus: "formula-bar",
activeRanges: [],
mode: "edit",
editorWidth,
editorHeight,
});
event.stopPropagation();
event.preventDefault();
}} }}
> >
{properties.formulaValue} <Editor
</Editor> originalText={formulaValue}
model={model}
workbookState={workbookState}
onEditEnd={() => {
onChange();
}}
onTextUpdated={onTextUpdated}
type="formula-bar"
/>
</EditorWrapper>
</FormulaContainer> </FormulaContainer>
<FormulaDialog
isOpen={formulaDialogOpen}
close={handleCloseFormulaDialog}
defaultFormula={properties.formulaValue}
onFormulaChanged={(newName) => {
properties.onChange(newName);
setFormulaDialogOpen(false);
}}
/>
</Container> </Container>
); );
} }
const StyledButton = styled(Button)` const StyledButton = styled("div")`
display: inline-flex;
align-items: center;
width: 15px; width: 15px;
min-width: 0px; min-width: 0px;
padding: 0px; padding: 0px;
@@ -78,7 +107,7 @@ const Divider = styled("div")`
`; `;
const FormulaContainer = styled("div")` const FormulaContainer = styled("div")`
margin-left: 10px; margin-left: 0px;
line-height: 22px; line-height: 22px;
font-weight: normal; font-weight: normal;
width: 100%; width: 100%;
@@ -93,7 +122,7 @@ const Container = styled("div")`
align-items: center; align-items: center;
background: ${(properties): string => background: ${(properties): string =>
properties.theme.palette.background.default}; properties.theme.palette.background.default};
height: ${formulaBarHeight}px; height: ${FORMULA_BAR_HEIGH}px;
`; `;
const AddressContainer = styled("div")` const AddressContainer = styled("div")`
@@ -113,7 +142,7 @@ const CellBarAddress = styled("div")`
text-align: "center"; text-align: "center";
`; `;
const Editor = styled("div")` const EditorWrapper = styled("div")`
position: relative; position: relative;
width: 100%; width: 100%;
padding: 0px; padding: 0px;
@@ -127,6 +156,7 @@ const Editor = styled("div")`
span { span {
min-width: 1px; min-width: 1px;
} }
font-family: monospace;
`; `;
export default FormulaBar; export default FormulaBar;

View File

@@ -9,10 +9,15 @@ import {
} from "@mui/material"; } from "@mui/material";
import Menu from "@mui/material/Menu"; import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { Check } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SheetOptions } from "./types"; import type { SheetOptions } from "./types";
function isWhiteColor(color: string): boolean {
return ["#FFF", "#FFFFFF"].includes(color);
}
interface SheetRenameDialogProps { interface SheetRenameDialogProps {
isOpen: boolean; isOpen: boolean;
close: () => void; close: () => void;
@@ -28,7 +33,7 @@ export const SheetRenameDialog = (properties: SheetRenameDialogProps) => {
<DialogTitle>{t("sheet_rename.title")}</DialogTitle> <DialogTitle>{t("sheet_rename.title")}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<TextField <TextField
defaultValue={name} defaultValue={properties.defaultName}
label={t("sheet_rename.label")} label={t("sheet_rename.label")}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -59,11 +64,20 @@ interface SheetListMenuProps {
anchorEl: HTMLButtonElement | null; anchorEl: HTMLButtonElement | null;
onSheetSelected: (index: number) => void; onSheetSelected: (index: number) => void;
sheetOptionsList: SheetOptions[]; sheetOptionsList: SheetOptions[];
selectedIndex: number;
} }
const SheetListMenu = (properties: SheetListMenuProps) => { const SheetListMenu = (properties: SheetListMenuProps) => {
const { isOpen, close, anchorEl, onSheetSelected, sheetOptionsList } = const {
properties; isOpen,
close,
anchorEl,
onSheetSelected,
sheetOptionsList,
selectedIndex,
} = properties;
const hasColors = sheetOptionsList.some((tab) => !isWhiteColor(tab.color));
return ( return (
<StyledMenu <StyledMenu
@@ -82,10 +96,23 @@ const SheetListMenu = (properties: SheetListMenuProps) => {
{sheetOptionsList.map((tab, index) => ( {sheetOptionsList.map((tab, index) => (
<StyledMenuItem <StyledMenuItem
key={tab.sheetId} key={tab.sheetId}
onClick={(): void => onSheetSelected(index)} onClick={() => onSheetSelected(index)}
> >
<ItemColor style={{ backgroundColor: tab.color }} /> {index === selectedIndex ? (
<ItemName>{tab.name}</ItemName> <Check
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
) : (
<div
style={{ width: "16px", height: "16px", marginRight: "8px" }}
/>
)}
{hasColors && <ItemColor style={{ backgroundColor: tab.color }} />}
<ItemName
style={{ fontWeight: index === selectedIndex ? "bold" : "normal" }}
>
{tab.name}
</ItemName>
</StyledMenuItem> </StyledMenuItem>
))} ))}
</StyledMenu> </StyledMenu>
@@ -115,7 +142,7 @@ const ItemColor = styled("div")`
`; `;
const ItemName = styled("div")` const ItemName = styled("div")`
font-size: 13px; font-size: 12px;
color: #333; color: #333;
`; `;

View File

@@ -1,8 +1,10 @@
import { styled } from "@mui/material"; import { styled } from "@mui/material";
import { ChevronLeft, ChevronRight, Menu, Plus } from "lucide-react"; 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 { NAVIGATION_HEIGH } from "../constants";
import { StyledButton } from "../toolbar"; import { StyledButton } from "../toolbar";
import type { WorkbookState } from "../workbookState";
import SheetListMenu from "./menus"; import SheetListMenu from "./menus";
import Sheet from "./sheet"; import Sheet from "./sheet";
import type { SheetOptions } from "./types"; import type { SheetOptions } from "./types";
@@ -10,6 +12,7 @@ import type { SheetOptions } from "./types";
export interface NavigationProps { export interface NavigationProps {
sheets: SheetOptions[]; sheets: SheetOptions[];
selectedIndex: number; selectedIndex: number;
workbookState: WorkbookState;
onSheetSelected: (index: number) => void; onSheetSelected: (index: number) => void;
onAddBlankSheet: () => void; onAddBlankSheet: () => void;
onSheetColorChanged: (hex: string) => void; onSheetColorChanged: (hex: string) => void;
@@ -19,7 +22,7 @@ export interface NavigationProps {
function Navigation(props: NavigationProps) { function Navigation(props: NavigationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { onSheetSelected, sheets, selectedIndex } = props; const { workbookState, onSheetSelected, sheets, selectedIndex } = props;
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -63,42 +66,29 @@ function Navigation(props: NavigationProps) {
onDeleted={(): void => { onDeleted={(): void => {
props.onSheetDeleted(); props.onSheetDeleted();
}} }}
workbookState={workbookState}
/> />
))} ))}
</SheetInner> </SheetInner>
</Sheets> </Sheets>
<LeftDivider /> <Advert href="https://www.ironcalc.com" target="_blank">
<ChevronLeftStyled /> ironcalc.com
<ChevronRightStyled /> </Advert>
<RightDivider />
<Advert>ironcalc.com</Advert>
<SheetListMenu <SheetListMenu
anchorEl={anchorEl} anchorEl={anchorEl}
isOpen={open} isOpen={open}
close={handleClose} close={handleClose}
sheetOptionsList={sheets} sheetOptionsList={sheets}
onSheetSelected={onSheetSelected} onSheetSelected={(index) => {
onSheetSelected(index);
handleClose();
}}
selectedIndex={selectedIndex}
/> />
</Container> </Container>
); );
} }
const ChevronLeftStyled = styled(ChevronLeft)`
color: #333333;
width: 16px;
height: 16px;
padding: 4px;
cursor: pointer;
`;
const ChevronRightStyled = styled(ChevronRight)`
color: #333333;
width: 16px;
height: 16px;
padding: 4px;
cursor: pointer;
`;
// Note I have to specify the font-family in every component that can be considered stand-alone // Note I have to specify the font-family in every component that can be considered stand-alone
const Container = styled("div")` const Container = styled("div")`
position: absolute; position: absolute;
@@ -106,11 +96,12 @@ const Container = styled("div")`
left: 0px; left: 0px;
right: 0px; right: 0px;
display: flex; display: flex;
height: 40px; height: ${NAVIGATION_HEIGH}px;
align-items: center; align-items: center;
padding-left: 12px; padding-left: 12px;
font-family: Inter; font-family: Inter;
background-color: #fff; background-color: #fff;
border-top: 1px solid #E0E0E0;
`; `;
const Sheets = styled("div")` const Sheets = styled("div")`
@@ -122,24 +113,11 @@ const SheetInner = styled("div")`
display: flex; display: flex;
`; `;
const LeftDivider = styled("div")` const Advert = styled("a")`
height: 10px;
width: 1px;
background-color: #eee;
margin: 0px 10px 0px 0px;
`;
const RightDivider = styled("div")`
height: 10px;
width: 1px;
background-color: #eee;
margin: 0px 20px 0px 10px;
`;
const Advert = styled("div")`
color: #f2994a; color: #f2994a;
margin-right: 12px; margin-right: 12px;
font-size: 12px; font-size: 12px;
text-decoration: none;
`; `;
export default Navigation; export default Navigation;

View File

@@ -2,7 +2,10 @@ import { Button, Menu, MenuItem, styled } from "@mui/material";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import ColorPicker from "../colorPicker"; import ColorPicker from "../colorPicker";
import { isInReferenceMode } from "../editor/util";
import type { WorkbookState } from "../workbookState";
import { SheetRenameDialog } from "./menus"; import { SheetRenameDialog } from "./menus";
interface SheetProps { interface SheetProps {
name: string; name: string;
color: string; color: string;
@@ -11,9 +14,11 @@ interface SheetProps {
onColorChanged: (hex: string) => void; onColorChanged: (hex: string) => void;
onRenamed: (name: string) => void; onRenamed: (name: string) => void;
onDeleted: () => void; onDeleted: () => void;
workbookState: WorkbookState;
} }
function Sheet(props: SheetProps) { function Sheet(props: SheetProps) {
const { name, color, selected, 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(null);
@@ -35,8 +40,18 @@ function Sheet(props: SheetProps) {
<> <>
<Wrapper <Wrapper
style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }} style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }}
onClick={() => { onClick={(event) => {
onSelected(); onSelected();
event.stopPropagation();
event.preventDefault();
}}
onPointerDown={(event) => {
// If it is in browse mode stop he event
const cell = workbookState.getEditingCell();
if (cell && isInReferenceMode(cell.text, cell.cursorStart)) {
event.stopPropagation();
event.preventDefault();
}
}} }}
ref={colorButton} ref={colorButton}
> >
@@ -58,23 +73,23 @@ function Sheet(props: SheetProps) {
horizontal: 6, horizontal: 6,
}} }}
> >
<MenuItem <StyledMenuItem
onClick={() => { onClick={() => {
handleOpenRenameDialog(); handleOpenRenameDialog();
handleClose(); handleClose();
}} }}
> >
Rename Rename
</MenuItem> </StyledMenuItem>
<MenuItem <StyledMenuItem
onClick={() => { onClick={() => {
setColorPickerOpen(true); setColorPickerOpen(true);
handleClose(); handleClose();
}} }}
> >
Change Color Change Color
</MenuItem> </StyledMenuItem>
<MenuItem <StyledMenuItem
onClick={() => { onClick={() => {
props.onDeleted(); props.onDeleted();
handleClose(); handleClose();
@@ -82,7 +97,7 @@ function Sheet(props: SheetProps) {
> >
{" "} {" "}
Delete Delete
</MenuItem> </StyledMenuItem>
</StyledMenu> </StyledMenu>
<SheetRenameDialog <SheetRenameDialog
isOpen={renameDialogOpen} isOpen={renameDialogOpen}
@@ -111,6 +126,10 @@ function Sheet(props: SheetProps) {
const StyledMenu = styled(Menu)``; const StyledMenu = styled(Menu)``;
const StyledMenuItem = styled(MenuItem)`
font-size: 12px;
`;
const StyledButton = styled(Button)` const StyledButton = styled(Button)`
width: 15px; width: 15px;
height: 24px; height: 24px;
@@ -131,6 +150,7 @@ const Wrapper = styled("div")`
border-top: 3px solid white; border-top: 3px solid white;
line-height: 34px; line-height: 34px;
align-items: center; align-items: center;
cursor: pointer;
`; `;
const Name = styled("div")` const Name = styled("div")`

View File

@@ -7,7 +7,7 @@ import { expect, test } from "vitest";
test("simple calculation", async () => { test("simple calculation", async () => {
const buffer = await readFile("node_modules/@ironcalc/wasm/wasm_bg.wasm"); const buffer = await readFile("node_modules/@ironcalc/wasm/wasm_bg.wasm");
initSync(buffer); initSync(buffer);
const model = new Model("en", "UTC"); const model = new Model("workbook", "en", "UTC");
model.setUserInput(0, 1, 1, "=21*2"); model.setUserInput(0, 1, 1, "=21*2");
expect(model.getFormattedCellValue(0, 1, 1)).toBe("42"); expect(model.getFormattedCellValue(0, 1, 1)).toBe("42");
}); });

View File

@@ -1,7 +1,26 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { decreaseDecimalPlaces, increaseDecimalPlaces } from "../formatUtil";
import { isNavigationKey } from "../util"; import { isNavigationKey } from "../util";
test("checks arrow left is a navigation key", () => { test("checks arrow left is a navigation key", () => {
expect(isNavigationKey("ArrowLeft")).toBe(true); expect(isNavigationKey("ArrowLeft")).toBe(true);
expect(isNavigationKey("Arrow")).toBe(false); expect(isNavigationKey("Arrow")).toBe(false);
}); });
test("increase decimals", () => {
expect(increaseDecimalPlaces('"€"#,##0.00'), '"€"#,##0.000');
expect(increaseDecimalPlaces("general"), "#,##0.000");
expect(
increaseDecimalPlaces('dddd"," mmmm dd"," yyyy'),
'dddd"," mmmm dd"," yyyy',
);
});
test("decrease decimals", () => {
expect(decreaseDecimalPlaces('"€"#,##0.00'), '"€"#,##0.0');
expect(decreaseDecimalPlaces("general"), "#,##0.0");
expect(
decreaseDecimalPlaces('dddd"," mmmm dd"," yyyy'),
'dddd"," mmmm dd"," yyyy',
);
});

View File

@@ -36,6 +36,7 @@ import {
import { theme } from "../theme"; import { theme } from "../theme";
import BorderPicker from "./borderPicker"; import BorderPicker from "./borderPicker";
import ColorPicker from "./colorPicker"; import ColorPicker from "./colorPicker";
import { TOOLBAR_HEIGH } from "./constants";
import FormatMenu from "./formatMenu"; import FormatMenu from "./formatMenu";
import { import {
NumberFormats, NumberFormats,
@@ -223,7 +224,7 @@ function Toolbar(properties: ToolbarProperties) {
$pressed={properties.strike} $pressed={properties.strike}
onClick={() => properties.onToggleStrike(!properties.strike)} onClick={() => properties.onToggleStrike(!properties.strike)}
disabled={!canEdit} disabled={!canEdit}
title={t("toolbar.strike_trough")} title={t("toolbar.strike_through")}
> >
<Strikethrough /> <Strikethrough />
</StyledButton> </StyledButton>
@@ -293,11 +294,7 @@ function Toolbar(properties: ToolbarProperties) {
<StyledButton <StyledButton
type="button" type="button"
$pressed={properties.verticalAlign === "top"} $pressed={properties.verticalAlign === "top"}
onClick={() => onClick={() => properties.onToggleVerticalAlign("top")}
properties.onToggleVerticalAlign(
properties.verticalAlign === "top" ? "bottom" : "top",
)
}
disabled={!canEdit} disabled={!canEdit}
title={t("toolbar.vertical_align_top")} title={t("toolbar.vertical_align_top")}
> >
@@ -306,13 +303,9 @@ function Toolbar(properties: ToolbarProperties) {
<StyledButton <StyledButton
type="button" type="button"
$pressed={properties.verticalAlign === "center"} $pressed={properties.verticalAlign === "center"}
onClick={() => onClick={() => properties.onToggleVerticalAlign("center")}
properties.onToggleVerticalAlign(
properties.verticalAlign === "center" ? "bottom" : "center",
)
}
disabled={!canEdit} disabled={!canEdit}
title={t("toolbar.vertical_align_center")} title={t("toolbar.vertical_align_middle")}
> >
<ArrowMiddleFromLine /> <ArrowMiddleFromLine />
</StyledButton> </StyledButton>
@@ -332,7 +325,7 @@ function Toolbar(properties: ToolbarProperties) {
onClick={() => setBorderPickerOpen(true)} onClick={() => setBorderPickerOpen(true)}
ref={borderButton} ref={borderButton}
disabled={!canEdit} disabled={!canEdit}
title={t("toolbar.borders")} title={t("toolbar.borders.title")}
> >
<Grid2X2 /> <Grid2X2 />
</StyledButton> </StyledButton>
@@ -376,6 +369,8 @@ function Toolbar(properties: ToolbarProperties) {
<BorderPicker <BorderPicker
onChange={(border): void => { onChange={(border): void => {
properties.onBorderChanged(border); properties.onBorderChanged(border);
}}
onClose={() => {
setBorderPickerOpen(false); setBorderPickerOpen(false);
}} }}
anchorEl={borderButton} anchorEl={borderButton}
@@ -384,19 +379,19 @@ function Toolbar(properties: ToolbarProperties) {
</ToolbarContainer> </ToolbarContainer>
); );
} }
const toolbarHeight = 40;
const ToolbarContainer = styled("div")` const ToolbarContainer = styled("div")`
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
background: ${({ theme }) => theme.palette.background.paper}; background: ${({ theme }) => theme.palette.background.paper};
height: ${toolbarHeight}px; height: ${TOOLBAR_HEIGH}px;
line-height: ${toolbarHeight}px; line-height: ${TOOLBAR_HEIGH}px;
border-bottom: 1px solid ${({ theme }) => theme.palette.grey["600"]}; border-bottom: 1px solid ${({ theme }) => theme.palette.grey["300"]};
font-family: Inter; font-family: Inter;
border-radius: 4px 4px 0px 0px; border-radius: 4px 4px 0px 0px;
overflow-x: auto; overflow-x: auto;
padding-left: 11px;
`; `;
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string }; type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
@@ -445,7 +440,7 @@ export const StyledButton = styled("button")<TypeButtonProperties>(
const Divider = styled("div")({ const Divider = styled("div")({
width: "0px", width: "0px",
height: "10px", height: "10px",
borderLeft: "1px solid #D3D6E9", borderLeft: "1px solid #E0E0E0",
marginLeft: "5px", marginLeft: "5px",
marginRight: "10px", marginRight: "10px",
}); });

View File

@@ -31,6 +31,7 @@ interface Options {
onRedo: () => void; onRedo: () => void;
onNextSheet: () => void; onNextSheet: () => void;
onPreviousSheet: () => void; onPreviousSheet: () => void;
onEscape: () => void;
root: RefObject<HTMLDivElement>; root: RefObject<HTMLDivElement>;
} }
@@ -212,6 +213,9 @@ const useKeyboardNavigation = (
break; break;
} }
case "Escape": {
options.onEscape();
}
// No default // No default
} }
event.stopPropagation(); event.stopPropagation();

View File

@@ -1,10 +1,14 @@
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 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 type { WorkbookState } from "./workbookState";
interface PointerSettings { interface PointerSettings {
canvasElement: RefObject<HTMLCanvasElement>; canvasElement: RefObject<HTMLCanvasElement>;
@@ -15,6 +19,9 @@ interface PointerSettings {
onAreaSelected: () => void; onAreaSelected: () => void;
onExtendToCell: (cell: Cell) => void; onExtendToCell: (cell: Cell) => void;
onExtendToEnd: () => void; onExtendToEnd: () => void;
model: Model;
workbookState: WorkbookState;
refresh: () => void;
} }
interface PointerEvents { interface PointerEvents {
@@ -27,6 +34,7 @@ interface PointerEvents {
const usePointer = (options: PointerSettings): PointerEvents => { const usePointer = (options: PointerSettings): PointerEvents => {
const isSelecting = useRef(false); const isSelecting = useRef(false);
const isExtending = useRef(false); const isExtending = useRef(false);
const isInsertingRef = useRef(false);
const onPointerMove = useCallback( const onPointerMove = useCallback(
(event: PointerEvent): void => { (event: PointerEvent): void => {
@@ -36,43 +44,50 @@ const usePointer = (options: PointerSettings): PointerEvents => {
return; return;
} }
if (
!(isSelecting.current || isExtending.current || isInsertingRef.current)
) {
return;
}
const { canvasElement, model, worksheetCanvas } = options;
const canvas = canvasElement.current;
const worksheet = worksheetCanvas.current;
// Silence the linter
if (!worksheet || !canvas) {
return;
}
const canvasRect = canvas.getBoundingClientRect();
const x = event.clientX - canvasRect.x;
const y = event.clientY - canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (!cell) {
return;
}
if (isSelecting.current) { if (isSelecting.current) {
const { canvasElement, worksheetCanvas } = options; options.onAreaSelecting(cell);
const canvas = canvasElement.current;
const worksheet = worksheetCanvas.current;
// Silence the linter
if (!worksheet || !canvas) {
return;
}
let x = event.clientX;
let y = event.clientY;
const canvasRect = canvas.getBoundingClientRect();
x -= canvasRect.x;
y -= canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (cell) {
options.onAreaSelecting(cell);
} else {
console.log("Failed");
}
} else if (isExtending.current) { } else if (isExtending.current) {
const { canvasElement, worksheetCanvas } = options;
const canvas = canvasElement.current;
const worksheet = worksheetCanvas.current;
// Silence the linter
if (!worksheet || !canvas) {
return;
}
let x = event.clientX;
let y = event.clientY;
const canvasRect = canvas.getBoundingClientRect();
x -= canvasRect.x;
y -= canvasRect.y;
const cell = worksheet.getCellByCoordinates(x, y);
if (!cell) {
return;
}
options.onExtendToCell(cell); options.onExtendToCell(cell);
} else if (isInsertingRef.current) {
const { refresh, workbookState } = options;
const editingCell = workbookState.getEditingCell();
if (!editingCell || !editingCell.referencedRange) {
return;
}
const range = editingCell.referencedRange.range;
range.rowEnd = cell.row;
range.columnEnd = cell.column;
const sheetNames = model.getWorksheetsProperties().map((s) => s.name);
editingCell.referencedRange.str = rangeToStr(
range,
editingCell.sheet,
sheetNames[range.sheet],
);
workbookState.setEditingCell(editingCell);
refresh();
} }
}, },
[options], [options],
@@ -90,6 +105,10 @@ const usePointer = (options: PointerSettings): PointerEvents => {
isExtending.current = false; isExtending.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId); worksheetElement.current?.releasePointerCapture(event.pointerId);
options.onExtendToEnd(); options.onExtendToEnd();
} else if (isInsertingRef.current) {
const { worksheetElement } = options;
isInsertingRef.current = false;
worksheetElement.current?.releasePointerCapture(event.pointerId);
} }
}, },
[options], [options],
@@ -99,7 +118,14 @@ const usePointer = (options: PointerSettings): PointerEvents => {
(event: PointerEvent) => { (event: PointerEvent) => {
let x = event.clientX; let x = event.clientX;
let y = event.clientY; let y = event.clientY;
const { canvasElement, worksheetElement, worksheetCanvas } = options; const {
canvasElement,
model,
refresh,
worksheetElement,
worksheetCanvas,
workbookState,
} = options;
const worksheet = worksheetCanvas.current; const worksheet = worksheetCanvas.current;
const canvas = canvasElement.current; const canvas = canvasElement.current;
const worksheetWrapper = worksheetElement.current; const worksheetWrapper = worksheetElement.current;
@@ -132,8 +158,60 @@ const usePointer = (options: PointerSettings): PointerEvents => {
} }
return; return;
} }
const editingCell = workbookState.getEditingCell();
const cell = worksheet.getCellByCoordinates(x, y); const cell = worksheet.getCellByCoordinates(x, y);
if (cell) { if (cell) {
if (editingCell) {
if (
cell.row === editingCell.row &&
cell.column === editingCell.column
) {
// We are clicking on the cell we are editing
// we do nothing
return;
}
// now we are editing one cell and we click in another one
// If we can insert a range we do that
const text = editingCell.text;
if (isInReferenceMode(text, editingCell.cursorEnd)) {
const range = {
sheet: model.getSelectedSheet(),
rowStart: cell.row,
rowEnd: cell.row,
columnStart: cell.column,
columnEnd: cell.column,
};
const sheetNames = model
.getWorksheetsProperties()
.map((s) => s.name);
editingCell.referencedRange = {
range,
str: rangeToStr(
range,
editingCell.sheet,
sheetNames[range.sheet],
),
};
workbookState.setEditingCell(editingCell);
event.stopPropagation();
event.preventDefault();
isInsertingRef.current = true;
worksheetWrapper.setPointerCapture(event.pointerId);
refresh();
return;
}
// We are clicking away but we are not in reference mode
// We finish the editing
workbookState.clearEditingCell();
model.setUserInput(
editingCell.sheet,
editingCell.row,
editingCell.column,
editingCell.text,
);
// we continue to select the new cell
}
options.onCellSelected(cell, event); options.onCellSelected(cell, event);
isSelecting.current = true; isSelecting.current = true;
worksheetWrapper.setPointerCapture(event.pointerId); worksheetWrapper.setPointerCapture(event.pointerId);

View File

@@ -29,14 +29,35 @@ export const isNavigationKey = (key: string): key is NavigationKey =>
key, key,
); );
export const getCellAddress = (selectedArea: Area, selectedCell?: Cell) => { export const getCellAddress = (selectedArea: Area, selectedCell: Cell) => {
const isSingleCell = const isSingleCell =
selectedArea.rowStart === selectedArea.rowEnd && selectedArea.rowStart === selectedArea.rowEnd &&
selectedArea.columnEnd === selectedArea.columnStart; selectedArea.columnEnd === selectedArea.columnStart;
return isSingleCell && selectedCell return isSingleCell
? `${columnNameFromNumber(selectedCell.column)}${selectedCell.row}` ? `${columnNameFromNumber(selectedCell.column)}${selectedCell.row}`
: `${columnNameFromNumber(selectedArea.columnStart)}${ : `${columnNameFromNumber(selectedArea.columnStart)}${
selectedArea.rowStart selectedArea.rowStart
}:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`; }:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`;
}; };
export function rangeToStr(
range: {
sheet: number;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
},
referenceSheet: number,
referenceName: string,
): string {
const { sheet, rowStart, rowEnd, columnStart, columnEnd } = range;
const sheetName = sheet === referenceSheet ? "" : `'${referenceName}'!`;
if (rowStart === rowEnd && columnStart === columnEnd) {
return `${sheetName}${columnNameFromNumber(columnStart)}${rowStart}`;
}
return `${sheetName}${columnNameFromNumber(
columnStart,
)}${rowStart}:${columnNameFromNumber(columnEnd)}${rowEnd}`;
}

View File

@@ -1,7 +1,20 @@
import type { BorderOptions, Model, WorksheetProperties } from "@ironcalc/wasm"; import type {
BorderOptions,
ClipboardCell,
Model,
WorksheetProperties,
} from "@ironcalc/wasm";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { LAST_COLUMN } from "./WorksheetCanvas/constants"; import {
COLUMN_WIDTH_SCALE,
LAST_COLUMN,
ROW_HEIGH_SCALE,
} from "./WorksheetCanvas/constants";
import {
CLIPBOARD_ID_SESSION_STORAGE_KEY,
getNewClipboardId,
} from "./clipboard";
import FormulaBar from "./formulabar"; import FormulaBar from "./formulabar";
import Navigation from "./navigation/navigation"; import Navigation from "./navigation/navigation";
import Toolbar from "./toolbar"; import Toolbar from "./toolbar";
@@ -15,14 +28,27 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
// Calling `setRedrawId((id) => id + 1);` forces a redraw // Calling `setRedrawId((id) => id + 1);` forces a redraw
// This is needed because `model` can change without React being aware of it // This is needed because `model` or `workbookState` can change without React being aware of it
const setRedrawId = useState(0)[1]; const setRedrawId = useState(0)[1];
const info = model const info = model
.getWorksheetsProperties() .getWorksheetsProperties()
.map(({ name, color, sheet_id }: WorksheetProperties) => { .map(({ name, color, sheet_id }: WorksheetProperties) => {
return { name, color: color ? color : "#FFF", sheetId: sheet_id }; return { name, color: color ? color : "#FFF", sheetId: sheet_id };
}); });
const focusWorkbook = useCallback(() => {
if (rootRef.current) {
rootRef.current.focus();
// HACK: We need to select something inside the root for onCopy to work
const selection = window.getSelection();
if (selection) {
selection.empty();
const range = new Range();
range.setStart(rootRef.current.firstChild as Node, 0);
range.setEnd(rootRef.current.firstChild as Node, 0);
selection.addRange(range);
}
}
}, []);
const onRedo = () => { const onRedo = () => {
model.redo(); model.redo();
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
@@ -125,8 +151,8 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
const row = Math.min(rowStart, rowEnd); const row = Math.min(rowStart, rowEnd);
const column = Math.min(columnStart, columnEnd); const column = Math.min(columnStart, columnEnd);
const width = Math.abs(columnEnd - columnStart) + 1; const width = Math.abs(columnEnd - columnStart);
const height = Math.abs(rowEnd - rowStart) + 1; const height = Math.abs(rowEnd - rowStart);
model.rangeClearContents( model.rangeClearContents(
sheet, sheet,
row, row,
@@ -143,11 +169,48 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}, },
onEditKeyPressStart: (initText: string): void => { onEditKeyPressStart: (initText: string): void => {
console.log(initText); const { sheet, row, column } = model.getSelectedView();
throw new Error("Function not implemented."); const editorWidth =
model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE;
const editorHeight = model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE;
workbookState.setEditingCell({
sheet,
row,
column,
text: initText,
cursorStart: initText.length,
cursorEnd: initText.length,
focus: "cell",
referencedRange: null,
activeRanges: [],
mode: "accept",
editorWidth,
editorHeight,
});
setRedrawId((id) => id + 1);
}, },
onCellEditStart: (): void => { onCellEditStart: (): void => {
throw new Error("Function not implemented."); // User presses F2, we start editing at the edn of the text
const { sheet, row, column } = model.getSelectedView();
const text = model.getCellContent(sheet, row, column);
const editorWidth =
model.getColumnWidth(sheet, column) * COLUMN_WIDTH_SCALE;
const editorHeight = model.getRowHeight(sheet, row) * ROW_HEIGH_SCALE;
workbookState.setEditingCell({
sheet,
row,
column,
text,
cursorStart: text.length,
cursorEnd: text.length,
referencedRange: null,
focus: "cell",
activeRanges: [],
mode: "edit",
editorWidth,
editorHeight,
});
setRedrawId((id) => id + 1);
}, },
onBold: () => { onBold: () => {
const { sheet, row, column } = model.getSelectedView(); const { sheet, row, column } = model.getSelectedView();
@@ -230,6 +293,10 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
model.setSelectedSheet(nextSheet); model.setSelectedSheet(nextSheet);
} }
}, },
onEscape: (): void => {
workbookState.clearCutRange();
setRedrawId((id) => id + 1);
},
root: rootRef, root: rootRef,
}); });
@@ -237,26 +304,197 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
if (!rootRef.current) { if (!rootRef.current) {
return; return;
} }
rootRef.current.focus(); if (!workbookState.getEditingCell()) {
focusWorkbook();
}
}); });
const { const cellAddress = useCallback(() => {
sheet, const {
row, row,
column, column,
range: [rowStart, columnStart, rowEnd, columnEnd], range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView(); } = model.getSelectedView();
return getCellAddress(
{ rowStart, rowEnd, columnStart, columnEnd },
{ row, column },
);
}, [model]);
const cellAddress = getCellAddress( const formulaValue = () => {
{ rowStart, rowEnd, columnStart, columnEnd }, const cell = workbookState.getEditingCell();
{ row, column }, if (cell) {
); return workbookState.getEditingText();
const formulaValue = model.getCellContent(sheet, row, column); }
const { sheet, row, column } = model.getSelectedView();
return model.getCellContent(sheet, row, column);
};
const style = model.getCellStyle(sheet, row, column); const getCellStyle = useCallback(() => {
const { sheet, row, column } = model.getSelectedView();
return model.getCellStyle(sheet, row, column);
}, [model]);
const style = getCellStyle();
return ( return (
<Container ref={rootRef} onKeyDown={onKeyDown} tabIndex={0}> <Container
ref={rootRef}
onKeyDown={onKeyDown}
tabIndex={0}
onClick={(event) => {
if (!workbookState.getEditingCell()) {
focusWorkbook();
} else {
event.stopPropagation();
}
}}
onPaste={(event: React.ClipboardEvent) => {
workbookState.clearCutRange();
const { items } = event.clipboardData;
if (!items) {
return;
}
const mimeTypes = [
"application/json",
"text/plain",
"text/csv",
"text/html",
];
let mimeType = null;
let value = null;
for (let index = 0; index < mimeTypes.length; index += 1) {
mimeType = mimeTypes[index];
value = event.clipboardData.getData(mimeType);
if (value) {
break;
}
}
if (!mimeType || !value) {
// No clipboard data to paste
return;
}
if (mimeType === "application/json") {
// We are copying from within the application
const source = JSON.parse(value);
// const clipboardId = sessionStorage.getItem(
// CLIPBOARD_ID_SESSION_STORAGE_KEY
// );
const data: Map<number, Map<number, ClipboardCell>> = new Map();
const sheetData = source.sheetData;
for (const row of Object.keys(sheetData)) {
const dataRow = sheetData[row];
const rowMap = new Map();
for (const column of Object.keys(dataRow)) {
rowMap.set(Number.parseInt(column, 10), dataRow[column]);
}
data.set(Number.parseInt(row, 10), rowMap);
}
model.pasteFromClipboard(source.area, data, source.type === "cut");
setRedrawId((id) => id + 1);
} else if (mimeType === "text/plain") {
const {
sheet,
range: [rowStart, columnStart, rowEnd, columnEnd],
} = model.getSelectedView();
const row = Math.min(rowStart, rowEnd);
const column = Math.min(columnStart, columnEnd);
const range = {
sheet,
row,
column,
width: Math.abs(columnEnd - columnStart) + 1,
height: Math.abs(rowEnd - rowStart) + 1,
};
model.pasteCsvText(range, value);
setRedrawId((id) => id + 1);
} else {
// NOT IMPLEMENTED
}
event.preventDefault();
event.stopPropagation();
}}
onCopy={(event: React.ClipboardEvent) => {
const data = model.copyToClipboard();
// '2024-10-18T14:07:37.599Z'
let clipboardId = sessionStorage.getItem(
CLIPBOARD_ID_SESSION_STORAGE_KEY,
);
if (!clipboardId) {
clipboardId = getNewClipboardId();
sessionStorage.setItem(CLIPBOARD_ID_SESSION_STORAGE_KEY, clipboardId);
}
const sheetData: {
[row: number]: {
[column: number]: ClipboardCell;
};
} = {};
data.data.forEach((value, row) => {
const rowData: {
[column: number]: ClipboardCell;
} = {};
value.forEach((val, column) => {
rowData[column] = val;
});
sheetData[row] = rowData;
});
const clipboardJsonStr = JSON.stringify({
type: "copy",
area: data.range,
sheetData,
clipboardId,
});
event.clipboardData.setData("text/plain", data.csv);
event.clipboardData.setData("application/json", clipboardJsonStr);
event.preventDefault();
event.stopPropagation();
}}
onCut={(event: React.ClipboardEvent) => {
const data = model.copyToClipboard();
// '2024-10-18T14:07:37.599Z'
let clipboardId = sessionStorage.getItem(
CLIPBOARD_ID_SESSION_STORAGE_KEY,
);
if (!clipboardId) {
clipboardId = getNewClipboardId();
sessionStorage.setItem(CLIPBOARD_ID_SESSION_STORAGE_KEY, clipboardId);
}
const sheetData: {
[row: number]: {
[column: number]: ClipboardCell;
};
} = {};
data.data.forEach((value, row) => {
const rowData: {
[column: number]: ClipboardCell;
} = {};
value.forEach((val, column) => {
rowData[column] = val;
});
sheetData[row] = rowData;
});
const clipboardJsonStr = JSON.stringify({
type: "cut",
area: data.range,
sheetData,
clipboardId,
});
event.clipboardData.setData("text/plain", data.csv);
event.clipboardData.setData("application/json", clipboardJsonStr);
workbookState.setCutRange({
sheet: model.getSelectedSheet(),
rowStart: data.range[0],
rowEnd: data.range[2],
columnStart: data.range[1],
columnEnd: data.range[3],
});
event.preventDefault();
event.stopPropagation();
setRedrawId((id) => id + 1);
}}
>
<Toolbar <Toolbar
canUndo={model.canUndo()} canUndo={model.canUndo()}
canRedo={model.canRedo()} canRedo={model.canRedo()}
@@ -301,22 +539,30 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
horizontalAlign={ horizontalAlign={
style.alignment ? style.alignment.horizontal : "general" style.alignment ? style.alignment.horizontal : "general"
} }
verticalAlign={style.alignment ? style.alignment.vertical : "center"} verticalAlign={
style.alignment?.vertical ? style.alignment.vertical : "bottom"
}
canEdit={true} canEdit={true}
numFmt={style.num_fmt} numFmt={style.num_fmt}
showGridLines={model.getShowGridLines(sheet)} showGridLines={model.getShowGridLines(model.getSelectedSheet())}
onToggleShowGridLines={(show) => { onToggleShowGridLines={(show) => {
const sheet = model.getSelectedSheet();
model.setShowGridLines(sheet, show); model.setShowGridLines(sheet, show);
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}} }}
/> />
<FormulaBar <FormulaBar
cellAddress={cellAddress} cellAddress={cellAddress()}
formulaValue={formulaValue} formulaValue={formulaValue()}
onChange={(value) => { onChange={() => {
model.setUserInput(sheet, row, column, value); setRedrawId((id) => id + 1);
focusWorkbook();
}}
onTextUpdated={() => {
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}} }}
model={model}
workbookState={workbookState}
/> />
<Worksheet <Worksheet
model={model} model={model}
@@ -325,9 +571,11 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
setRedrawId((id) => id + 1); setRedrawId((id) => id + 1);
}} }}
/> />
<Navigation <Navigation
sheets={info} sheets={info}
selectedIndex={model.getSelectedSheet()} selectedIndex={model.getSelectedSheet()}
workbookState={workbookState}
onSheetSelected={(sheet: number): void => { onSheetSelected={(sheet: number): void => {
model.setSelectedSheet(sheet); model.setSelectedSheet(sheet);
setRedrawId((value) => value + 1); setRedrawId((value) => value + 1);
@@ -368,6 +616,7 @@ const Container = styled("div")`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative;
font-family: ${({ theme }) => theme.typography.fontFamily}; font-family: ${({ theme }) => theme.typography.fontFamily};
&:focus { &:focus {

View File

@@ -1,5 +1,28 @@
// This are properties of the workbook that are not permanently stored
// They only happen at 'runtime' while the workbook is being used:
//
// * What are we editing
// * Are we copying styles?
// * Are we extending a cell? (by pulling the cell outline handle down, for instance)
//
// Editing the cell is the most complex operation.
//
// * What cell are we editing?
// * Are we doing that from the cell editor or the formula editor?
// * What is the text content of the cell right now
// * The active ranges can technically be computed from the text.
// Those are the ranges or cells that appear in the formula
import type { CellStyle } from "@ironcalc/wasm"; import type { CellStyle } from "@ironcalc/wasm";
export interface CutRange {
sheet: number;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
}
export enum AreaType { export enum AreaType {
rowsDown = 0, rowsDown = 0,
columnsRight = 1, columnsRight = 1,
@@ -15,15 +38,67 @@ export interface Area {
columnEnd: number; columnEnd: number;
} }
// Active ranges are ranges in the sheet that are highlighted when editing a formula
export interface ActiveRange {
sheet: number;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
color: string;
}
export interface ReferencedRange {
range: {
sheet: number;
rowStart: number;
rowEnd: number;
columnStart: number;
columnEnd: number;
};
str: string;
}
type Focus = "cell" | "formula-bar";
type EditorMode = "accept" | "edit";
// In "edit" mode arrow keys will move you around the text in the editor
// In "accept" mode arrow keys will accept the content and move to the next cell or select another cell
// The cell that we are editing
export interface EditingCell {
sheet: number;
row: number;
column: number;
// raw text in the editor
text: string;
// position of the cursor
cursorStart: number;
cursorEnd: number;
// referenced range
referencedRange: ReferencedRange | null;
focus: Focus;
activeRanges: ActiveRange[];
mode: EditorMode;
editorWidth: number;
editorHeight: number;
}
// Those are styles that are copied
type AreaStyles = CellStyle[][]; type AreaStyles = CellStyle[][];
export class WorkbookState { export class WorkbookState {
private extendToArea: Area | null; private extendToArea: Area | null;
private copyStyles: AreaStyles | null; private copyStyles: AreaStyles | null;
private cell: EditingCell | null;
private cutRange: CutRange | null;
constructor() { constructor() {
// the extendTo area is the area we are covering
this.extendToArea = null; this.extendToArea = null;
this.copyStyles = null; this.copyStyles = null;
this.cell = null;
this.cutRange = null;
} }
getExtendToArea(): Area | null { getExtendToArea(): Area | null {
@@ -45,4 +120,61 @@ export class WorkbookState {
getCopyStyles(): AreaStyles | null { getCopyStyles(): AreaStyles | null {
return this.copyStyles; return this.copyStyles;
} }
setActiveRanges(activeRanges: ActiveRange[]) {
if (!this.cell) {
return;
}
this.cell.activeRanges = activeRanges;
}
getActiveRanges(): ActiveRange[] {
return this.cell?.activeRanges || [];
}
getEditingCell(): EditingCell | null {
return this.cell;
}
setEditingCell(cell: EditingCell) {
this.cell = cell;
}
clearEditingCell() {
this.cell = null;
}
isCellEditorActive(): boolean {
if (this.cell) {
return this.cell.focus === "cell";
}
return false;
}
isFormulaEditorActive(): boolean {
if (this.cell) {
return this.cell.focus === "formula-bar";
}
return false;
}
getEditingText(): string {
const cell = this.cell;
if (cell) {
return cell.text + (cell.referencedRange?.str || "");
}
return "";
}
setCutRange(range: CutRange): void {
this.cutRange = range;
}
clearCutRange(): void {
this.cutRange = null;
}
getCutRange(): CutRange | null {
return this.cutRange;
}
} }

View File

@@ -2,10 +2,18 @@ import type { Model } from "@ironcalc/wasm";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { import {
COLUMN_WIDTH_SCALE,
ROW_HEIGH_SCALE,
outlineBackgroundColor, outlineBackgroundColor,
outlineColor, outlineColor,
} from "./WorksheetCanvas/constants"; } from "./WorksheetCanvas/constants";
import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas"; import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
import {
FORMULA_BAR_HEIGH,
NAVIGATION_HEIGH,
TOOLBAR_HEIGH,
} from "./constants";
import Editor from "./editor/editor";
import type { Cell } from "./types"; import type { Cell } from "./types";
import usePointer from "./usePointer"; import usePointer from "./usePointer";
import { AreaType, type WorkbookState } from "./workbookState"; import { AreaType, type WorkbookState } from "./workbookState";
@@ -32,7 +40,8 @@ function Worksheet(props: {
const worksheetElement = useRef<HTMLDivElement>(null); const worksheetElement = useRef<HTMLDivElement>(null);
const scrollElement = useRef<HTMLDivElement>(null); const scrollElement = useRef<HTMLDivElement>(null);
// const rootElement = useRef<HTMLDivElement>(null);
const editorElement = useRef<HTMLDivElement>(null);
const spacerElement = useRef<HTMLDivElement>(null); const spacerElement = useRef<HTMLDivElement>(null);
const cellOutline = useRef<HTMLDivElement>(null); const cellOutline = useRef<HTMLDivElement>(null);
const areaOutline = useRef<HTMLDivElement>(null); const areaOutline = useRef<HTMLDivElement>(null);
@@ -47,6 +56,7 @@ function Worksheet(props: {
const { model, workbookState, refresh } = props; const { model, workbookState, refresh } = props;
const [clientWidth, clientHeight] = useWindowSize(); const [clientWidth, clientHeight] = useWindowSize();
useEffect(() => { useEffect(() => {
const canvasRef = canvasElement.current; const canvasRef = canvasElement.current;
const columnGuideRef = columnResizeGuide.current; const columnGuideRef = columnResizeGuide.current;
@@ -58,6 +68,7 @@ function Worksheet(props: {
const handle = cellOutlineHandle.current; const handle = cellOutlineHandle.current;
const area = areaOutline.current; const area = areaOutline.current;
const extendTo = extendToOutline.current; const extendTo = extendToOutline.current;
const editor = editorElement.current;
if ( if (
!canvasRef || !canvasRef ||
@@ -69,11 +80,13 @@ function Worksheet(props: {
!handle || !handle ||
!area || !area ||
!extendTo || !extendTo ||
!scrollElement.current !scrollElement.current ||
!editor
) )
return; return;
// FIXME: This two need to be computed.
model.setWindowWidth(clientWidth - 37); model.setWindowWidth(clientWidth - 37);
model.setWindowHeight(clientHeight - 149); model.setWindowHeight(clientHeight - 190);
const canvas = new WorksheetCanvas({ const canvas = new WorksheetCanvas({
width: worksheetRef.clientWidth, width: worksheetRef.clientWidth,
height: worksheetRef.clientHeight, height: worksheetRef.clientHeight,
@@ -88,6 +101,7 @@ function Worksheet(props: {
cellOutlineHandle: handle, cellOutlineHandle: handle,
areaOutline: area, areaOutline: area,
extendToOutline: extendTo, extendToOutline: extendTo,
editor: editor,
}, },
onColumnWidthChanges(sheet, column, width) { onColumnWidthChanges(sheet, column, width) {
model.setColumnWidth(sheet, column, width); model.setColumnWidth(sheet, column, width);
@@ -100,7 +114,7 @@ function Worksheet(props: {
}); });
const scrollX = model.getScrollX(); const scrollX = model.getScrollX();
const scrollY = model.getScrollY(); const scrollY = model.getScrollY();
const [sheetWidth, sheetHeight] = [scrollX + 100_000, scrollY + 500_000]; //canvas.getSheetDimensions(); const [sheetWidth, sheetHeight] = [scrollX + 100_000, scrollY + 500_000];
if (spacerElement.current) { if (spacerElement.current) {
spacerElement.current.style.height = `${sheetHeight}px`; spacerElement.current.style.height = `${sheetHeight}px`;
spacerElement.current.style.width = `${sheetWidth}px`; spacerElement.current.style.width = `${sheetWidth}px`;
@@ -127,10 +141,6 @@ function Worksheet(props: {
worksheetCanvas.current = canvas; worksheetCanvas.current = canvas;
}); });
const sheetNames = model
.getWorksheetsProperties()
.map((s: { name: string }) => s.name);
const { const {
onPointerMove, onPointerMove,
onPointerDown, onPointerDown,
@@ -138,6 +148,9 @@ function Worksheet(props: {
onPointerUp, onPointerUp,
// onContextMenu, // onContextMenu,
} = usePointer({ } = usePointer({
model,
workbookState,
refresh,
onCellSelected: (cell: Cell, event: React.MouseEvent) => { onCellSelected: (cell: Cell, event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -152,6 +165,7 @@ function Worksheet(props: {
const { row, column } = cell; const { row, column } = cell;
model.onAreaSelecting(row, column); model.onAreaSelecting(row, column);
canvas.renderSheet(); canvas.renderSheet();
refresh();
}, },
onAreaSelected: () => { onAreaSelected: () => {
const styles = workbookState.getCopyStyles(); const styles = workbookState.getCopyStyles();
@@ -167,6 +181,7 @@ function Worksheet(props: {
if (worksheetElement.current) { if (worksheetElement.current) {
worksheetElement.current.style.cursor = "auto"; worksheetElement.current.style.cursor = "auto";
} }
refresh();
}, },
onExtendToCell: (cell) => { onExtendToCell: (cell) => {
const canvas = worksheetCanvas.current; const canvas = worksheetCanvas.current;
@@ -281,6 +296,12 @@ function Worksheet(props: {
break; 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(); workbookState.clearExtendToArea();
canvas.renderSheet(); canvas.renderSheet();
}, },
@@ -310,21 +331,51 @@ function Worksheet(props: {
<SheetContainer <SheetContainer
className="sheet-container" className="sheet-container"
ref={worksheetElement} ref={worksheetElement}
onPointerDown={(event) => { onPointerDown={onPointerDown}
onPointerDown(event);
}}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onDoubleClick={(event) => { onDoubleClick={(event) => {
// Starts editing cell
const { sheet, row, column } = model.getSelectedView(); const { sheet, row, column } = model.getSelectedView();
const _text = model.getCellContent(sheet, row, column) || ""; const text = model.getCellContent(sheet, row, column);
// TODO 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.stopPropagation();
event.preventDefault(); // event.preventDefault();
props.refresh();
}} }}
> >
<SheetCanvas ref={canvasElement} /> <SheetCanvas ref={canvasElement} />
<CellOutline ref={cellOutline} /> <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} /> <AreaOutline ref={areaOutline} />
<ExtendToOutline ref={extendToOutline} /> <ExtendToOutline ref={extendToOutline} />
<CellOutlineHandle <CellOutlineHandle
@@ -382,10 +433,11 @@ const SheetContainer = styled("div")`
const Wrapper = styled("div")({ const Wrapper = styled("div")({
position: "absolute", position: "absolute",
overflow: "scroll", overflow: "scroll",
top: 71, top: TOOLBAR_HEIGH + FORMULA_BAR_HEIGH + 1,
left: 0, left: 0,
right: 0, right: 0,
bottom: 41, bottom: NAVIGATION_HEIGH + 1,
overscrollBehavior: "none",
}); });
const SheetCanvas = styled("canvas")` const SheetCanvas = styled("canvas")`
@@ -451,7 +503,6 @@ const CellOutlineHandle = styled("div")`
height: 5px; height: 5px;
background: ${outlineColor}; background: ${outlineColor};
cursor: crosshair; cursor: crosshair;
// border: 1px solid white;
border-radius: 1px; border-radius: 1px;
`; `;
@@ -461,4 +512,22 @@ const ExtendToOutline = styled("div")`
border-radius: 3px; 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; export default Worksheet;

View File

@@ -1,15 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- <path d="M14 4H2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/> <line x1="0" y1="2" x2="16" y2="2" stroke="#000"/>
<path d="M14 8H2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 2"/>
<path d="M14 12H2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="0.01 2"/> -->
<style>
line {
stroke: black;
}
</style>
<line x1="0" y1="2" x2="16" y2="2" />
<!-- Dashes and gaps of the same size --> <!-- Dashes and gaps of the same size -->
<line x1="0" y1="8" x2="16" y2="8" stroke-dasharray="2.28 2.28" /> <line x1="0" y1="8" x2="16" y2="8" stroke-dasharray="2.28 2.28" stroke="#000"/>
<!-- Dashes and gaps of different sizes --> <!-- Dashes and gaps of different sizes -->
<line x1="0" y1="14" x2="16" y2="14" stroke-dasharray="1 2" /> <line x1="0" y1="14" x2="16" y2="14" stroke-dasharray="1 2" stroke="#000"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -20,6 +20,9 @@ import InsertColumnRightIcon from "./insert-column-right.svg?react";
import InsertRowAboveIcon from "./insert-row-above.svg?react"; import InsertRowAboveIcon from "./insert-row-above.svg?react";
import InsertRowBelow from "./insert-row-below.svg?react"; import InsertRowBelow from "./insert-row-below.svg?react";
import IronCalcIcon from "./ironcalc_icon.svg?react";
import IronCalcLogo from "./orange+black.svg?react";
import Fx from "./fx.svg?react"; import Fx from "./fx.svg?react";
export { export {
@@ -42,5 +45,7 @@ export {
InsertColumnRightIcon, InsertColumnRightIcon,
InsertRowAboveIcon, InsertRowAboveIcon,
InsertRowBelow, InsertRowBelow,
IronCalcIcon,
IronCalcLogo,
Fx, Fx,
}; };

View File

@@ -0,0 +1,7 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M62.2449 0C62.2449 16.5084 55.687 32.3406 44.0138 44.0138C42.0408 45.9868 39.949 47.8137 37.7551 49.4875L37.7551 100C37.7551 83.4916 44.3131 67.6594 55.9863 55.9862C57.9593 54.0132 60.0511 52.1863 62.2449 50.5125L62.2449 0Z" fill="#F2994A"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M37.7552 0.0239258C37.7488 10.0285 33.7717 19.622 26.697 26.6968C19.6165 33.7773 10.0133 37.755 6.10352e-05 37.755V62.2448C13.7182 62.2448 26.9694 57.7164 37.7552 49.4874V0.0239258Z" fill="#F2994A"/>
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M62.2449 99.976C62.2513 89.9713 66.2283 80.3779 73.3031 73.3031C80.3836 66.2226 89.9868 62.2449 100 62.2449V37.7551C86.2819 37.7551 73.0307 42.2835 62.2449 50.5125V99.976Z" fill="#F2994A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.0138 44.0138C55.687 32.3406 62.2449 16.5084 62.2449 0H37.7551V49.4875C39.949 47.8137 42.0408 45.9868 44.0138 44.0138Z" fill="#F2994A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M37.7551 99.9655C37.7551 99.977 37.7551 99.9885 37.7551 100H62.2449C62.2449 99.9912 62.2449 99.9825 62.2449 99.9737V50.5125C60.0511 52.1863 57.9593 54.0132 55.9862 55.9862C44.3212 67.6513 37.7643 83.4696 37.7551 99.9655Z" fill="#F2994A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -1,4 +1,5 @@
body { body {
inset: 0px;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }

View File

@@ -15,15 +15,18 @@
"format_number": "Format number", "format_number": "Format number",
"font_color": "Font color", "font_color": "Font color",
"fill_color": "Fill color", "fill_color": "Fill color",
"borders": "Borders",
"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",
"vertical_align_bottom": "Align bottom",
"vertical_align_middle": " Align middle",
"vertical_align_top": "Align top",
"format_menu": { "format_menu": {
"auto": "Auto", "auto": "Auto",
"number": "Number", "number": "Number",
"percentage": "Percentage", "percentage": "Percentage",
"currency_eur": "Euro (EUR)", "currency_eur": "Euro (EUR)",
"currency_usd": "Dollar (USD", "currency_usd": "Dollar (USD)",
"currency_gbp": "British Pound (GBD)", "currency_gbp": "British Pound (GBD)",
"date_short": "Short date", "date_short": "Short date",
"date_long": "Long date", "date_long": "Long date",
@@ -35,6 +38,21 @@
"currency_gbp_example": "£", "currency_gbp_example": "£",
"date_short_example": "09/24/2024", "date_short_example": "09/24/2024",
"date_long_example": "Tuesday, September 24, 2024" "date_long_example": "Tuesday, September 24, 2024"
},
"borders": {
"title": "Borders",
"all": "All borders",
"inner": "Inner borders",
"outer": "Outer borders",
"top": "Top borders",
"bottom": "Bottom borders",
"clear": "Clear borders",
"left": "Left borders",
"right": "Right borders",
"horizontal": "Horizontal borders",
"vertical": "Vertical borders",
"color": "Border color",
"style": "Border style"
} }
}, },
"num_fmt": { "num_fmt": {
@@ -51,5 +69,9 @@
"update": "Update", "update": "Update",
"label": "Formula", "label": "Formula",
"title": "Update formula" "title": "Update formula"
},
"navigation": {
"add_sheet": "Add sheet",
"sheet_list": "Sheet list"
} }
} }

View File

@@ -1,8 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import "./index.css"; import "./index.css";
import ThemeProvider from "@mui/material/styles/ThemeProvider"; import ThemeProvider from "@mui/material/styles/ThemeProvider";
import React from "react";
import { theme } from "./theme.ts"; import { theme } from "./theme.ts";
// biome-ignore lint: we know the 'root' element exists. // biome-ignore lint: we know the 'root' element exists.