Compare commits
63 Commits
feature/ni
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24dd63b261 | ||
|
|
861700cb45 | ||
|
|
98dc557a01 | ||
|
|
2c2228c2c2 | ||
|
|
494a315cbd | ||
|
|
0c69889832 | ||
|
|
04d8c658ab | ||
|
|
dad4755b16 | ||
|
|
75d8a5282e | ||
|
|
f78027247b | ||
|
|
ee6a41c4f4 | ||
|
|
b7336f70d6 | ||
|
|
dae37f14ba | ||
|
|
7ffbfac432 | ||
|
|
f9ea4fd757 | ||
|
|
7446932519 | ||
|
|
d55845e69f | ||
|
|
9e5b959ccc | ||
|
|
ffa93309e2 | ||
|
|
79216b286b | ||
|
|
411d4a3780 | ||
|
|
3a7aa15347 | ||
|
|
090e852054 | ||
|
|
3e54ad5b3c | ||
|
|
7b12c2682e | ||
|
|
80273a88ec | ||
|
|
3d951c5c50 | ||
|
|
cd54389e91 | ||
|
|
843d8beb02 | ||
|
|
09ac29785d | ||
|
|
2b530423c8 | ||
|
|
51c41900d7 | ||
|
|
730a815729 | ||
|
|
9805d0c518 | ||
|
|
10a9d36f3d | ||
|
|
480640dc98 | ||
|
|
3058a63e4f | ||
|
|
8275d73b64 | ||
|
|
072abb2240 | ||
|
|
9a46e5ccc7 | ||
|
|
585e594d8d | ||
|
|
248ef66e7c | ||
|
|
15da2e5785 | ||
|
|
39174add1f | ||
|
|
e412f5fc22 | ||
|
|
42c1a39131 | ||
|
|
f26cdd3a4b | ||
|
|
4016eb5944 | ||
|
|
58dfdd329e | ||
|
|
4a290aec7c | ||
|
|
3966dbc790 | ||
|
|
abd4ce4ea5 | ||
|
|
02da1eb388 | ||
|
|
1131234531 | ||
|
|
b495397b5f | ||
|
|
8c0a566995 | ||
|
|
dd62dd2dc6 | ||
|
|
79b7b9b817 | ||
|
|
06ae1a1d6d | ||
|
|
6390739fd4 | ||
|
|
e41741cf77 | ||
|
|
48719b6416 | ||
|
|
53d3d5144c |
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal 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
112
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
164
base/src/test/user_model/test_paste_csv.rs
Normal file
164
base/src/test/user_model/test_paste_csv.rs
Normal 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), ©.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), ©.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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
124
webapp/src/AppComponents/FileBar.tsx
Normal file
124
webapp/src/AppComponents/FileBar.tsx
Normal 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;
|
||||||
|
`;
|
||||||
184
webapp/src/AppComponents/FileMenu.tsx
Normal file
184
webapp/src/AppComponents/FileMenu.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
30
webapp/src/AppComponents/ShareButton.tsx
Normal file
30
webapp/src/AppComponents/ShareButton.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
271
webapp/src/AppComponents/UploadFileDialog.tsx
Normal file
271
webapp/src/AppComponents/UploadFileDialog.tsx
Normal 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;
|
||||||
|
`;
|
||||||
104
webapp/src/AppComponents/WorkbookTitle.tsx
Normal file
104
webapp/src/AppComponents/WorkbookTitle.tsx
Normal 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;
|
||||||
|
`;
|
||||||
70
webapp/src/AppComponents/rpc.ts
Normal file
70
webapp/src/AppComponents/rpc.ts
Normal 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();
|
||||||
|
}
|
||||||
129
webapp/src/AppComponents/storage.ts
Normal file
129
webapp/src/AppComponents/storage.ts
Normal 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]);
|
||||||
|
}
|
||||||
18
webapp/src/AppComponents/util.ts
Normal file
18
webapp/src/AppComponents/util.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
2
webapp/src/components/clipboard.ts
Normal file
2
webapp/src/components/clipboard.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const getNewClipboardId = () => new Date().toISOString();
|
||||||
|
export const CLIPBOARD_ID_SESSION_STORAGE_KEY = "IronCalc-Clipboard";
|
||||||
3
webapp/src/components/constants.tsx
Normal file
3
webapp/src/components/constants.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const TOOLBAR_HEIGH = 48;
|
||||||
|
export const FORMULA_BAR_HEIGH = 40;
|
||||||
|
export const NAVIGATION_HEIGH = 40;
|
||||||
255
webapp/src/components/editor/editor.tsx
Normal file
255
webapp/src/components/editor/editor.tsx
Normal 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;
|
||||||
401
webapp/src/components/editor/useKeyDown.ts
Normal file
401
webapp/src/components/editor/useKeyDown.ts
Normal 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;
|
||||||
189
webapp/src/components/editor/util.tsx
Normal file
189
webapp/src/components/editor/util.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")`
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 |
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
7
webapp/src/icons/ironcalc_icon.svg
Normal file
7
webapp/src/icons/ironcalc_icon.svg
Normal 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 |
8
webapp/src/icons/orange+black.svg
Normal file
8
webapp/src/icons/orange+black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.9 KiB |
@@ -1,4 +1,5 @@
|
|||||||
body {
|
body {
|
||||||
|
inset: 0px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user