Compare commits
1 Commits
feature/ni
...
feature/ni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71eaf1dd7 |
2
.gitignore
vendored
@@ -1,3 +1 @@
|
|||||||
target/*
|
target/*
|
||||||
**/node_modules/*
|
|
||||||
.DS_Store
|
|
||||||
|
|||||||
444
Cargo.lock
generated
@@ -19,18 +19,6 @@ dependencies = [
|
|||||||
"cpufeatures",
|
"cpufeatures",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ahash"
|
|
||||||
version = "0.8.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"once_cell",
|
|
||||||
"version_check",
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.3"
|
version = "1.1.3"
|
||||||
@@ -40,12 +28,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "allocator-api2"
|
|
||||||
version = "0.2.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android-tzdata"
|
name = "android-tzdata"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -94,12 +76,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitflags"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -148,21 +124,6 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cassowary"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "castaway"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc"
|
|
||||||
dependencies = [
|
|
||||||
"rustversion",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.0.90"
|
version = "1.0.90"
|
||||||
@@ -190,7 +151,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-targets 0.52.4",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -225,19 +186,6 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "compact_str"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
|
|
||||||
dependencies = [
|
|
||||||
"castaway",
|
|
||||||
"cfg-if",
|
|
||||||
"itoa",
|
|
||||||
"ryu",
|
|
||||||
"static_assertions",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console_error_panic_hook"
|
name = "console_error_panic_hook"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -284,31 +232,6 @@ version = "0.8.19"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
|
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossterm"
|
|
||||||
version = "0.27.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"crossterm_winapi",
|
|
||||||
"libc",
|
|
||||||
"mio",
|
|
||||||
"parking_lot",
|
|
||||||
"signal-hook",
|
|
||||||
"signal-hook-mio",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossterm_winapi"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
|
||||||
dependencies = [
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -376,22 +299,6 @@ dependencies = [
|
|||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
"allocator-api2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "heck"
|
|
||||||
version = "0.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac"
|
name = "hmac"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -424,12 +331,6 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "indoc"
|
|
||||||
version = "2.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -469,6 +370,7 @@ dependencies = [
|
|||||||
"ryu",
|
"ryu",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -510,31 +412,12 @@ version = "0.2.153"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
|
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lock_api"
|
|
||||||
version = "0.4.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
|
|
||||||
dependencies = [
|
|
||||||
"autocfg",
|
|
||||||
"scopeguard",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.21"
|
version = "0.4.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
|
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lru"
|
|
||||||
version = "0.12.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc"
|
|
||||||
dependencies = [
|
|
||||||
"hashbrown",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.2"
|
version = "2.7.2"
|
||||||
@@ -550,18 +433,6 @@ dependencies = [
|
|||||||
"adler",
|
"adler",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mio"
|
|
||||||
version = "0.8.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"wasi",
|
|
||||||
"windows-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -583,29 +454,6 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parking_lot"
|
|
||||||
version = "0.12.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb"
|
|
||||||
dependencies = [
|
|
||||||
"lock_api",
|
|
||||||
"parking_lot_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parking_lot_core"
|
|
||||||
version = "0.9.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"redox_syscall",
|
|
||||||
"smallvec",
|
|
||||||
"windows-targets 0.52.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parse-zoneinfo"
|
name = "parse-zoneinfo"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -626,12 +474,6 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "paste"
|
|
||||||
version = "1.0.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pbkdf2"
|
name = "pbkdf2"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -748,35 +590,6 @@ dependencies = [
|
|||||||
"getrandom",
|
"getrandom",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ratatui"
|
|
||||||
version = "0.26.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"cassowary",
|
|
||||||
"compact_str",
|
|
||||||
"crossterm",
|
|
||||||
"indoc",
|
|
||||||
"itertools",
|
|
||||||
"lru",
|
|
||||||
"paste",
|
|
||||||
"stability",
|
|
||||||
"strum",
|
|
||||||
"unicode-segmentation",
|
|
||||||
"unicode-width",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_syscall"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.10.4"
|
version = "1.10.4"
|
||||||
@@ -812,12 +625,6 @@ version = "0.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f"
|
checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustversion"
|
|
||||||
version = "1.0.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -830,12 +637,6 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "scopeguard"
|
|
||||||
version = "1.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.197"
|
version = "1.0.197"
|
||||||
@@ -878,6 +679,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_repr"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -900,86 +712,12 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal-hook"
|
|
||||||
version = "0.3.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"signal-hook-registry",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal-hook-mio"
|
|
||||||
version = "0.2.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"mio",
|
|
||||||
"signal-hook",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal-hook-registry"
|
|
||||||
version = "1.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "smallvec"
|
|
||||||
version = "1.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "stability"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a"
|
|
||||||
dependencies = [
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "static_assertions"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strum"
|
|
||||||
version = "0.26.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29"
|
|
||||||
dependencies = [
|
|
||||||
"strum_macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strum_macros"
|
|
||||||
version = "0.26.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946"
|
|
||||||
dependencies = [
|
|
||||||
"heck",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"rustversion",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1036,26 +774,6 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
|
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tiron"
|
|
||||||
version = "0.1.3"
|
|
||||||
dependencies = [
|
|
||||||
"crossterm",
|
|
||||||
"ironcalc",
|
|
||||||
"ratatui",
|
|
||||||
"tui-input",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tui-input"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b3e785f863a3af4c800a2a669d0b64c879b538738e352607e2624d03f868dc01"
|
|
||||||
dependencies = [
|
|
||||||
"crossterm",
|
|
||||||
"unicode-width",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
@@ -1068,18 +786,6 @@ version = "1.0.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-segmentation"
|
|
||||||
version = "1.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-width"
|
|
||||||
version = "0.1.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
@@ -1214,59 +920,13 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi"
|
|
||||||
version = "0.3.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
|
||||||
dependencies = [
|
|
||||||
"winapi-i686-pc-windows-gnu",
|
|
||||||
"winapi-x86_64-pc-windows-gnu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-i686-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets 0.52.4",
|
"windows-targets",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.48.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets 0.48.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-targets"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
|
||||||
dependencies = [
|
|
||||||
"windows_aarch64_gnullvm 0.48.5",
|
|
||||||
"windows_aarch64_msvc 0.48.5",
|
|
||||||
"windows_i686_gnu 0.48.5",
|
|
||||||
"windows_i686_msvc 0.48.5",
|
|
||||||
"windows_x86_64_gnu 0.48.5",
|
|
||||||
"windows_x86_64_gnullvm 0.48.5",
|
|
||||||
"windows_x86_64_msvc 0.48.5",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1275,119 +935,57 @@ version = "0.52.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b"
|
checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm 0.52.4",
|
"windows_aarch64_gnullvm",
|
||||||
"windows_aarch64_msvc 0.52.4",
|
"windows_aarch64_msvc",
|
||||||
"windows_i686_gnu 0.52.4",
|
"windows_i686_gnu",
|
||||||
"windows_i686_msvc 0.52.4",
|
"windows_i686_msvc",
|
||||||
"windows_x86_64_gnu 0.52.4",
|
"windows_x86_64_gnu",
|
||||||
"windows_x86_64_gnullvm 0.52.4",
|
"windows_x86_64_gnullvm",
|
||||||
"windows_x86_64_msvc 0.52.4",
|
"windows_x86_64_msvc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_gnullvm"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9"
|
checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675"
|
checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3"
|
checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02"
|
checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnu"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03"
|
checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnullvm"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177"
|
checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_msvc"
|
|
||||||
version = "0.48.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
|
checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy"
|
|
||||||
version = "0.7.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy-derive"
|
|
||||||
version = "0.7.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "0.6.6"
|
version = "0.6.6"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"base",
|
"base",
|
||||||
"xlsx",
|
"xlsx",
|
||||||
"tironcalc",
|
|
||||||
"bindings/wasm",
|
"bindings/wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
12
Makefile
@@ -1,6 +1,13 @@
|
|||||||
|
all:
|
||||||
|
cargo build --release
|
||||||
|
cd bindings/wasm/ && make
|
||||||
|
cd webapp && npm install && npm run build
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
cargo fmt -- --check
|
cargo fmt -- --check
|
||||||
cargo clippy --all-targets --all-features
|
# TODO: See issue #33
|
||||||
|
# cargo clippy --all-targets --all-features -- -D warnings -D clippy::expect_used -D clippy::unwrap_used -D clippy::panic
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
format:
|
format:
|
||||||
cargo fmt
|
cargo fmt
|
||||||
@@ -10,7 +17,7 @@ tests: lint
|
|||||||
./target/debug/documentation
|
./target/debug/documentation
|
||||||
cmp functions.md wiki/functions.md || exit 1
|
cmp functions.md wiki/functions.md || exit 1
|
||||||
make remove-artifacts
|
make remove-artifacts
|
||||||
cd bindings/wasm/ && wasm-pack build --target nodejs && node tests/test.mjs
|
cd bindings/wasm/ && make tests
|
||||||
|
|
||||||
remove-artifacts:
|
remove-artifacts:
|
||||||
rm -f xlsx/hello-calc.xlsx
|
rm -f xlsx/hello-calc.xlsx
|
||||||
@@ -25,6 +32,7 @@ clean: remove-artifacts
|
|||||||
rm -f cargo-test-*
|
rm -f cargo-test-*
|
||||||
rm -f base/cargo-test-*
|
rm -f base/cargo-test-*
|
||||||
rm -f xlsx/cargo-test-*
|
rm -f xlsx/cargo-test-*
|
||||||
|
rm -r -f webapp/node_modules
|
||||||
|
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 441 B |
|
Before Width: | Height: | Size: 729 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 33 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="600" height="600" rx="20" fill="#F2994A"/>
|
|
||||||
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M348.98 100C348.98 166.034 322.748 229.362 276.055 276.055C268.163 283.947 259.796 291.255 251.021 297.95L251.021 500L348.98 500H251.021C251.021 433.966 277.252 370.637 323.945 323.945C331.837 316.053 340.204 308.745 348.98 302.05L348.98 100Z" fill="white"/>
|
|
||||||
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M251.021 100.068C251.003 140.096 235.094 178.481 206.788 206.787C178.466 235.109 140.053 251.02 100 251.02V348.979C154.873 348.979 207.877 330.866 251.021 297.95V100.068Z" fill="white"/>
|
|
||||||
<path opacity="0.8" fill-rule="evenodd" clip-rule="evenodd" d="M348.98 499.882C349.011 459.872 364.918 421.507 393.213 393.213C421.534 364.891 459.947 348.98 500 348.98V251.02C445.128 251.02 392.123 269.134 348.98 302.05V499.882Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M276.055 276.055C322.748 229.362 348.98 166.034 348.98 100H251.021V297.95C259.796 291.255 268.163 283.947 276.055 276.055Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M348.98 302.05V499.895C348.98 499.93 348.98 499.965 348.98 500L251.021 500C251.021 499.946 251.02 499.891 251.021 499.837C251.064 433.862 277.291 370.599 323.945 323.945C331.837 316.053 340.204 308.745 348.98 302.05Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
assets/logo.png
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
@@ -12,6 +12,8 @@ readme = "README.md"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde_repr = "0.1"
|
||||||
ryu = "1.0"
|
ryu = "1.0"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = "0.9"
|
chrono-tz = "0.9"
|
||||||
@@ -19,9 +21,6 @@ regex = "1.0"
|
|||||||
once_cell = "1.16.0"
|
once_cell = "1.16.0"
|
||||||
bitcode = "0.6.0"
|
bitcode = "0.6.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json = "1.0"
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
js-sys = { version = "0.3.69" }
|
js-sys = { version = "0.3.69" }
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
expressions::token::Error, language::Language, number_format::to_excel_precision_str, types::*,
|
expressions::token::Error, language::Language, number_format::to_excel_precision_str, types::*,
|
||||||
};
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
/// A CellValue is the representation of the cell content.
|
/// A CellValue is the representation of the cell content.
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
||||||
|
#[serde(untagged)]
|
||||||
pub enum CellValue {
|
pub enum CellValue {
|
||||||
None,
|
None,
|
||||||
String(String),
|
String(String),
|
||||||
@@ -11,6 +14,17 @@ pub enum CellValue {
|
|||||||
Boolean(bool),
|
Boolean(bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CellValue {
|
||||||
|
pub fn to_json_str(&self) -> String {
|
||||||
|
match &self {
|
||||||
|
CellValue::None => "null".to_string(),
|
||||||
|
CellValue::String(s) => json!(s).to_string(),
|
||||||
|
CellValue::Number(f) => json!(f).to_string(),
|
||||||
|
CellValue::Boolean(b) => json!(b).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<f64> for CellValue {
|
impl From<f64> for CellValue {
|
||||||
fn from(value: f64) -> Self {
|
fn from(value: f64) -> Self {
|
||||||
Self::Number(value)
|
Self::Number(value)
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ impl Parser {
|
|||||||
|
|
||||||
pub fn parse(&mut self, formula: &str, context: &Option<CellReferenceRC>) -> Node {
|
pub fn parse(&mut self, formula: &str, context: &Option<CellReferenceRC>) -> Node {
|
||||||
self.lexer.set_formula(formula);
|
self.lexer.set_formula(formula);
|
||||||
self.context.clone_from(context);
|
self.context = context.clone();
|
||||||
self.parse_expr()
|
self.parse_expr()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::fmt;
|
|||||||
|
|
||||||
use bitcode::{Decode, Encode};
|
use bitcode::{Decode, Encode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||||
|
|
||||||
use crate::language::Language;
|
use crate::language::Language;
|
||||||
|
|
||||||
@@ -80,7 +81,8 @@ impl fmt::Display for OpProduct {
|
|||||||
/// * "#ERROR!" means there was an error processing the formula (for instance "=A1+")
|
/// * "#ERROR!" means there was an error processing the formula (for instance "=A1+")
|
||||||
/// * "#N/IMPL!" means the formula or feature in Excel but has not been implemented in IronCalc
|
/// * "#N/IMPL!" means the formula or feature in Excel but has not been implemented in IronCalc
|
||||||
/// Note that they are serialized/deserialized by index
|
/// Note that they are serialized/deserialized by index
|
||||||
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize_repr, Deserialize_repr, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
|
#[repr(u8)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
REF,
|
REF,
|
||||||
NAME,
|
NAME,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
PfrendeesD<>VRAITRUEWAHRVERDADEROTVFAUXFALSEFALSCHFALSOUw#REF!#REF!#BEZUG!#¡REF!e<>#NOM?#NAME?#NAME?#¿NOMBRE?x<>#VALEUR!#VALUE!#WERT!#¡VALOR!w<>#DIV/0!#DIV/0!#DIV/0!#¡DIV/0!<04>#N/A#N/A#NV#N/AXv#NOMBRE!#NUM!#ZAHL!#¡NUM!<02><>#N/IMPL!#N/IMPL!#N/IMPL!#N/IMPL!w{#SPILL!#SPILL!#ÜBERLAUF!#SPILL!ff#CALC!#CALC!#CALC!#CALC!ff#CIRC!#CIRC!#CIRC!#CIRC!ww#ERROR!#ERROR!#ERROR!#ERROR!ff#NULL!#NULL!#NULL!#NULL!
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use bitcode::{Decode, Encode};
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
|
||||||
pub struct Booleans {
|
pub struct Booleans {
|
||||||
pub r#true: String,
|
pub r#true: String,
|
||||||
pub r#false: String,
|
pub r#false: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct Errors {
|
pub struct Errors {
|
||||||
pub r#ref: String,
|
pub r#ref: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -25,14 +25,14 @@ pub struct Errors {
|
|||||||
pub null: String,
|
pub null: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct Language {
|
pub struct Language {
|
||||||
pub booleans: Booleans,
|
pub booleans: Booleans,
|
||||||
pub errors: Errors,
|
pub errors: Errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
static LANGUAGES: Lazy<HashMap<String, Language>> = Lazy::new(|| {
|
static LANGUAGES: Lazy<HashMap<String, Language>> = Lazy::new(|| {
|
||||||
bitcode::decode(include_bytes!("language.bin")).expect("Failed parsing language file")
|
serde_json::from_str(include_str!("language.json")).expect("Failed parsing language file")
|
||||||
});
|
});
|
||||||
|
|
||||||
pub fn get_language(id: &str) -> Result<&Language, String> {
|
pub fn get_language(id: &str) -> Result<&Language, String> {
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
use bitcode::{Decode, Encode};
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct Locale {
|
pub struct Locale {
|
||||||
pub dates: Dates,
|
pub dates: Dates,
|
||||||
pub numbers: NumbersProperties,
|
pub numbers: NumbersProperties,
|
||||||
pub currency: Currency,
|
pub currency: Currency,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct Currency {
|
pub struct Currency {
|
||||||
pub iso: String,
|
pub iso: String,
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct NumbersProperties {
|
pub struct NumbersProperties {
|
||||||
|
#[serde(rename = "symbols-numberSystem-latn")]
|
||||||
pub symbols: NumbersSymbols,
|
pub symbols: NumbersSymbols,
|
||||||
|
#[serde(rename = "decimalFormats-numberSystem-latn")]
|
||||||
pub decimal_formats: DecimalFormats,
|
pub decimal_formats: DecimalFormats,
|
||||||
|
#[serde(rename = "currencyFormats-numberSystem-latn")]
|
||||||
pub currency_formats: CurrencyFormats,
|
pub currency_formats: CurrencyFormats,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct Dates {
|
pub struct Dates {
|
||||||
pub day_names: Vec<String>,
|
pub day_names: Vec<String>,
|
||||||
pub day_names_short: Vec<String>,
|
pub day_names_short: Vec<String>,
|
||||||
@@ -32,7 +35,8 @@ pub struct Dates {
|
|||||||
pub months_letter: Vec<String>,
|
pub months_letter: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct NumbersSymbols {
|
pub struct NumbersSymbols {
|
||||||
pub decimal: String,
|
pub decimal: String,
|
||||||
pub group: String,
|
pub group: String,
|
||||||
@@ -50,23 +54,31 @@ pub struct NumbersSymbols {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// See: https://cldr.unicode.org/translation/number-currency-formats/number-and-currency-patterns
|
// See: https://cldr.unicode.org/translation/number-currency-formats/number-and-currency-patterns
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct CurrencyFormats {
|
pub struct CurrencyFormats {
|
||||||
pub standard: String,
|
pub standard: String,
|
||||||
|
#[serde(rename = "standard-alphaNextToNumber")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub standard_alpha_next_to_number: Option<String>,
|
pub standard_alpha_next_to_number: Option<String>,
|
||||||
|
#[serde(rename = "standard-noCurrency")]
|
||||||
pub standard_no_currency: String,
|
pub standard_no_currency: String,
|
||||||
pub accounting: String,
|
pub accounting: String,
|
||||||
|
#[serde(rename = "accounting-alphaNextToNumber")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub accounting_alpha_next_to_number: Option<String>,
|
pub accounting_alpha_next_to_number: Option<String>,
|
||||||
|
#[serde(rename = "accounting-noCurrency")]
|
||||||
pub accounting_no_currency: String,
|
pub accounting_no_currency: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DecimalFormats {
|
pub struct DecimalFormats {
|
||||||
pub standard: String,
|
pub standard: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
static LOCALES: Lazy<HashMap<String, Locale>> =
|
static LOCALES: Lazy<HashMap<String, Locale>> = Lazy::new(|| {
|
||||||
Lazy::new(|| bitcode::decode(include_bytes!("locales.bin")).expect("Failed parsing locale"));
|
serde_json::from_str(include_str!("locales.json")).expect("Failed parsing locale")
|
||||||
|
});
|
||||||
|
|
||||||
pub fn get_locale(id: &str) -> Result<&Locale, String> {
|
pub fn get_locale(id: &str) -> Result<&Locale, String> {
|
||||||
// TODO: pass the locale once we implement locales in Rust
|
// TODO: pass the locale once we implement locales in Rust
|
||||||
|
|||||||
@@ -118,8 +118,6 @@ pub struct Model {
|
|||||||
pub(crate) language: Language,
|
pub(crate) language: Language,
|
||||||
/// The timezone used to evaluate the model
|
/// The timezone used to evaluate the model
|
||||||
pub(crate) tz: Tz,
|
pub(crate) tz: Tz,
|
||||||
/// The view id. A view consist of a selected sheet and ranges.
|
|
||||||
pub(crate) view_id: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Maybe this should be the same as CellReference
|
// FIXME: Maybe this should be the same as CellReference
|
||||||
@@ -683,13 +681,6 @@ impl Model {
|
|||||||
Err(format!("Invalid color: {}", color))
|
Err(format!("Invalid color: {}", color))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Makes the grid lines in the sheet visible (`true`) or hidden (`false`)
|
|
||||||
pub fn set_show_grid_lines(&mut self, sheet: u32, show_grid_lines: bool) -> Result<(), String> {
|
|
||||||
let worksheet = self.workbook.worksheet_mut(sheet)?;
|
|
||||||
worksheet.show_grid_lines = show_grid_lines;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_cell_value(&self, cell: &Cell, cell_reference: CellReferenceIndex) -> CalcResult {
|
fn get_cell_value(&self, cell: &Cell, cell_reference: CellReferenceIndex) -> CalcResult {
|
||||||
use Cell::*;
|
use Cell::*;
|
||||||
match cell {
|
match cell {
|
||||||
@@ -895,7 +886,6 @@ impl Model {
|
|||||||
language,
|
language,
|
||||||
locale,
|
locale,
|
||||||
tz,
|
tz,
|
||||||
view_id: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
model.parse_formulas();
|
model.parse_formulas();
|
||||||
|
|||||||
@@ -6,18 +6,14 @@ use crate::{
|
|||||||
calc_result::Range,
|
calc_result::Range,
|
||||||
expressions::{
|
expressions::{
|
||||||
lexer::LexerMode,
|
lexer::LexerMode,
|
||||||
parser::{
|
parser::stringify::{rename_sheet_in_node, to_rc_format},
|
||||||
stringify::{rename_sheet_in_node, to_rc_format},
|
parser::Parser,
|
||||||
Parser,
|
|
||||||
},
|
|
||||||
types::CellReferenceRC,
|
types::CellReferenceRC,
|
||||||
},
|
},
|
||||||
language::get_language,
|
language::get_language,
|
||||||
locale::get_locale,
|
locale::get_locale,
|
||||||
model::{get_milliseconds_since_epoch, Model, ParsedDefinedName},
|
model::{get_milliseconds_since_epoch, Model, ParsedDefinedName},
|
||||||
types::{
|
types::{Metadata, SheetState, Workbook, WorkbookSettings, Worksheet},
|
||||||
Metadata, SheetState, Workbook, WorkbookSettings, WorkbookView, Worksheet, WorksheetView,
|
|
||||||
},
|
|
||||||
utils::ParsedReference,
|
utils::ParsedReference,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,20 +33,7 @@ fn is_valid_sheet_name(name: &str) -> bool {
|
|||||||
|
|
||||||
impl Model {
|
impl Model {
|
||||||
/// Creates a new worksheet. Note that it does not check if the name or the sheet_id exists
|
/// Creates a new worksheet. Note that it does not check if the name or the sheet_id exists
|
||||||
fn new_empty_worksheet(name: &str, sheet_id: u32, view_ids: &[&u32]) -> Worksheet {
|
fn new_empty_worksheet(name: &str, sheet_id: u32) -> Worksheet {
|
||||||
let mut views = HashMap::new();
|
|
||||||
for id in view_ids {
|
|
||||||
views.insert(
|
|
||||||
**id,
|
|
||||||
WorksheetView {
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
range: [1, 1, 1, 1],
|
|
||||||
top_row: 1,
|
|
||||||
left_column: 1,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Worksheet {
|
Worksheet {
|
||||||
cols: vec![],
|
cols: vec![],
|
||||||
rows: vec![],
|
rows: vec![],
|
||||||
@@ -65,8 +48,6 @@ impl Model {
|
|||||||
color: Default::default(),
|
color: Default::default(),
|
||||||
frozen_columns: 0,
|
frozen_columns: 0,
|
||||||
frozen_rows: 0,
|
frozen_rows: 0,
|
||||||
show_grid_lines: true,
|
|
||||||
views,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +122,7 @@ impl Model {
|
|||||||
self.parsed_defined_names = parsed_defined_names;
|
self.parsed_defined_names = parsed_defined_names;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reparses all formulas and defined names
|
// Reparses all formulas and defined names
|
||||||
pub(crate) fn reset_parsed_structures(&mut self) {
|
pub(crate) fn reset_parsed_structures(&mut self) {
|
||||||
self.parser
|
self.parser
|
||||||
.set_worksheets(self.workbook.get_worksheet_names());
|
.set_worksheets(self.workbook.get_worksheet_names());
|
||||||
@@ -172,8 +153,7 @@ impl Model {
|
|||||||
let sheet_name = format!("{}{}", base_name, index);
|
let sheet_name = format!("{}{}", base_name, index);
|
||||||
// Now we need a sheet_id
|
// Now we need a sheet_id
|
||||||
let sheet_id = self.get_new_sheet_id();
|
let sheet_id = self.get_new_sheet_id();
|
||||||
let view_ids: Vec<&u32> = self.workbook.views.keys().collect();
|
let worksheet = Model::new_empty_worksheet(&sheet_name, sheet_id);
|
||||||
let worksheet = Model::new_empty_worksheet(&sheet_name, sheet_id, &view_ids);
|
|
||||||
self.workbook.worksheets.push(worksheet);
|
self.workbook.worksheets.push(worksheet);
|
||||||
self.reset_parsed_structures();
|
self.reset_parsed_structures();
|
||||||
(sheet_name, self.workbook.worksheets.len() as u32 - 1)
|
(sheet_name, self.workbook.worksheets.len() as u32 - 1)
|
||||||
@@ -204,8 +184,7 @@ impl Model {
|
|||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => self.get_new_sheet_id(),
|
None => self.get_new_sheet_id(),
|
||||||
};
|
};
|
||||||
let view_ids: Vec<&u32> = self.workbook.views.keys().collect();
|
let worksheet = Model::new_empty_worksheet(sheet_name, sheet_id);
|
||||||
let worksheet = Model::new_empty_worksheet(sheet_name, sheet_id, &view_ids);
|
|
||||||
if sheet_index as usize > self.workbook.worksheets.len() {
|
if sheet_index as usize > self.workbook.worksheets.len() {
|
||||||
return Err("Sheet index out of range".to_string());
|
return Err("Sheet index out of range".to_string());
|
||||||
}
|
}
|
||||||
@@ -352,14 +331,11 @@ impl Model {
|
|||||||
// "2020-08-06T21:20:53Z
|
// "2020-08-06T21:20:53Z
|
||||||
let now = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
let now = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
|
||||||
let mut views = HashMap::new();
|
|
||||||
views.insert(0, WorkbookView { sheet: 0 });
|
|
||||||
|
|
||||||
// String versions of the locale are added here to simplify the serialize/deserialize logic
|
// String versions of the locale are added here to simplify the serialize/deserialize logic
|
||||||
let workbook = Workbook {
|
let workbook = Workbook {
|
||||||
shared_strings: vec![],
|
shared_strings: vec![],
|
||||||
defined_names: vec![],
|
defined_names: vec![],
|
||||||
worksheets: vec![Model::new_empty_worksheet("Sheet1", 1, &[&0])],
|
worksheets: vec![Model::new_empty_worksheet("Sheet1", 1)],
|
||||||
styles: Default::default(),
|
styles: Default::default(),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
settings: WorkbookSettings {
|
settings: WorkbookSettings {
|
||||||
@@ -375,7 +351,6 @@ impl Model {
|
|||||||
last_modified: now,
|
last_modified: now,
|
||||||
},
|
},
|
||||||
tables: HashMap::new(),
|
tables: HashMap::new(),
|
||||||
views,
|
|
||||||
};
|
};
|
||||||
let parsed_formulas = Vec::new();
|
let parsed_formulas = Vec::new();
|
||||||
let worksheets = &workbook.worksheets;
|
let worksheets = &workbook.worksheets;
|
||||||
@@ -396,7 +371,6 @@ impl Model {
|
|||||||
locale,
|
locale,
|
||||||
language,
|
language,
|
||||||
tz,
|
tz,
|
||||||
view_id: 0,
|
|
||||||
};
|
};
|
||||||
model.parse_formulas();
|
model.parse_formulas();
|
||||||
Ok(model)
|
Ok(model)
|
||||||
|
|||||||
@@ -76,16 +76,10 @@ fn fn_imconjugate() {
|
|||||||
fn fn_imcos() {
|
fn fn_imcos() {
|
||||||
let mut model = new_empty_model();
|
let mut model = new_empty_model();
|
||||||
model._set("A1", r#"=IMCOS("4+3i")"#);
|
model._set("A1", r#"=IMCOS("4+3i")"#);
|
||||||
// In macos non intel this is "-6.58066304055116+7.58155274274655i"
|
|
||||||
model._set("A2", r#"=COMPLEX(-6.58066304055116, 7.58155274274654)"#);
|
|
||||||
model._set("A3", r#"=IMABS(IMSUB(A1, A2)) < G1"#);
|
|
||||||
|
|
||||||
// small number
|
|
||||||
model._set("G1", "0.0000001");
|
|
||||||
|
|
||||||
model.evaluate();
|
model.evaluate();
|
||||||
|
|
||||||
assert_eq!(model._get_text("A3"), "TRUE");
|
assert_eq!(model._get_text("A1"), "-6.58066304055116+7.58155274274654i");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -53,5 +53,4 @@ mod test_frozen_rows_and_columns;
|
|||||||
mod test_get_cell_content;
|
mod test_get_cell_content;
|
||||||
mod test_percentage;
|
mod test_percentage;
|
||||||
mod test_today;
|
mod test_today;
|
||||||
mod test_types;
|
|
||||||
mod user_model;
|
mod user_model;
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#![allow(clippy::unwrap_used)]
|
|
||||||
|
|
||||||
use crate::types::{Alignment, HorizontalAlignment, VerticalAlignment};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn alignment_default() {
|
|
||||||
let alignment = Alignment::default();
|
|
||||||
assert_eq!(
|
|
||||||
alignment,
|
|
||||||
Alignment {
|
|
||||||
horizontal: HorizontalAlignment::General,
|
|
||||||
vertical: VerticalAlignment::Bottom,
|
|
||||||
wrap_text: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let s = serde_json::to_string(&alignment).unwrap();
|
|
||||||
// defaults stringifies as an empty object
|
|
||||||
assert_eq!(s, "{}");
|
|
||||||
|
|
||||||
let a: Alignment = serde_json::from_str("{}").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(a, alignment)
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
mod test_add_delete_sheets;
|
mod test_add_delete_sheets;
|
||||||
mod test_autofill_columns;
|
|
||||||
mod test_autofill_rows;
|
|
||||||
mod test_clear_cells;
|
mod test_clear_cells;
|
||||||
mod test_diff_queue;
|
mod test_diff_queue;
|
||||||
mod test_evaluation;
|
mod test_evaluation;
|
||||||
mod test_general;
|
mod test_general;
|
||||||
mod test_grid_lines;
|
|
||||||
mod test_rename_sheet;
|
mod test_rename_sheet;
|
||||||
mod test_row_column;
|
mod test_row_column;
|
||||||
mod test_styles;
|
mod test_styles;
|
||||||
mod test_to_from_bytes;
|
mod test_to_from_bytes;
|
||||||
mod test_undo_redo;
|
mod test_undo_redo;
|
||||||
mod test_view;
|
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
#![allow(clippy::unwrap_used)]
|
|
||||||
|
|
||||||
use crate::constants::{LAST_COLUMN, LAST_ROW};
|
|
||||||
use crate::expressions::types::Area;
|
|
||||||
use crate::test::util::new_empty_model;
|
|
||||||
use crate::UserModel;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn basic_tests() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// This is cell A3
|
|
||||||
model.set_user_input(0, 3, 1, "alpha").unwrap();
|
|
||||||
// We autofill from A3 to C3
|
|
||||||
model
|
|
||||||
.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 3,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// B3
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 3, 2),
|
|
||||||
Ok("alpha".to_string())
|
|
||||||
);
|
|
||||||
// C3
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 3, 3),
|
|
||||||
Ok("alpha".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn one_cell_right() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.set_user_input(0, 1, 1, "23").unwrap();
|
|
||||||
model
|
|
||||||
.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// B1
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 2),
|
|
||||||
Ok("23".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn alpha_beta_gamma() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A1:B3
|
|
||||||
model.set_user_input(0, 1, 1, "Alpher").unwrap(); // A1
|
|
||||||
model.set_user_input(0, 1, 2, "Bethe").unwrap(); // B1
|
|
||||||
model.set_user_input(0, 1, 3, "Gamow").unwrap(); // C1
|
|
||||||
model.set_user_input(0, 2, 1, "=A1").unwrap(); // A2
|
|
||||||
model.set_user_input(0, 2, 2, "=B1").unwrap(); // B2
|
|
||||||
model.set_user_input(0, 2, 3, "=C1").unwrap(); // C2
|
|
||||||
|
|
||||||
// We autofill from A1:C2 to I2
|
|
||||||
model
|
|
||||||
.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
width: 3,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
9,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// D1
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 4),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 5),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 6),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 7),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 8),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 9),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 2, 4),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 2, 5),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 2, 6),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 2, 7),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 2, 8),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 2, 9),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(model.get_cell_content(0, 2, 4), Ok("=D1".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn styles() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A1:C1
|
|
||||||
model.set_user_input(0, 1, 1, "Alpher").unwrap();
|
|
||||||
model.set_user_input(0, 2, 1, "Bethe").unwrap();
|
|
||||||
model.set_user_input(0, 3, 1, "Gamow").unwrap();
|
|
||||||
|
|
||||||
let b1 = Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 2,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let c1 = Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 3,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
model.update_range_style(&b1, "font.i", "true").unwrap();
|
|
||||||
model
|
|
||||||
.update_range_style(&c1, "fill.bg_color", "#334455")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
model
|
|
||||||
.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
width: 3,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
9,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Check that cell E1 has B1 style
|
|
||||||
let style = model.get_cell_style(0, 1, 5).unwrap();
|
|
||||||
assert!(style.font.i);
|
|
||||||
// A6 would have the style of A3
|
|
||||||
let style = model.get_cell_style(0, 1, 6).unwrap();
|
|
||||||
assert_eq!(style.fill.bg_color, Some("#334455".to_string()));
|
|
||||||
|
|
||||||
model.undo().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(model.get_cell_content(0, 1, 4), Ok("".to_string()));
|
|
||||||
// Check that cell A5 has A2 style
|
|
||||||
let style = model.get_cell_style(0, 1, 5).unwrap();
|
|
||||||
assert!(!style.font.i);
|
|
||||||
// A6 would have the style of A3
|
|
||||||
let style = model.get_cell_style(0, 1, 6).unwrap();
|
|
||||||
assert_eq!(style.fill.bg_color, None);
|
|
||||||
|
|
||||||
model.redo().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 4),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
// Check that cell A5 has A2 style
|
|
||||||
let style = model.get_cell_style(0, 1, 5).unwrap();
|
|
||||||
assert!(style.font.i);
|
|
||||||
// A6 would have the style of A3
|
|
||||||
let style = model.get_cell_style(0, 1, 6).unwrap();
|
|
||||||
assert_eq!(style.fill.bg_color, Some("#334455".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn left() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A10:A12
|
|
||||||
model.set_user_input(0, 1, 10, "Alpher").unwrap();
|
|
||||||
model.set_user_input(0, 1, 11, "Bethe").unwrap();
|
|
||||||
model.set_user_input(0, 1, 12, "Gamow").unwrap();
|
|
||||||
|
|
||||||
// We fill upwards to row 5
|
|
||||||
model
|
|
||||||
.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 10,
|
|
||||||
width: 3,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 9),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 8),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 7),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn left_4() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A10:A13
|
|
||||||
model.set_user_input(0, 1, 10, "Margaret Burbidge").unwrap();
|
|
||||||
model.set_user_input(0, 1, 11, "Geoffrey Burbidge").unwrap();
|
|
||||||
model.set_user_input(0, 1, 12, "Willy Fowler").unwrap();
|
|
||||||
model.set_user_input(0, 1, 13, "Fred Hoyle").unwrap();
|
|
||||||
|
|
||||||
// We fill left to row 5
|
|
||||||
model
|
|
||||||
.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 10,
|
|
||||||
width: 4,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 9),
|
|
||||||
Ok("Fred Hoyle".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 8),
|
|
||||||
Ok("Willy Fowler".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 1, 5),
|
|
||||||
Ok("Fred Hoyle".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn errors() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
|
|
||||||
model.set_user_input(0, 1, 4, "Margaret Burbidge").unwrap();
|
|
||||||
|
|
||||||
// Invalid sheet
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 3,
|
|
||||||
row: 1,
|
|
||||||
column: 4,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid worksheet index: '3'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// invalid column
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: -1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid column: '-1'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// invalid column
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: LAST_COLUMN - 1,
|
|
||||||
width: 10,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid column: '16392'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: LAST_ROW + 1,
|
|
||||||
column: 1,
|
|
||||||
width: 10,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid row: '1048577'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: LAST_ROW - 2,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 10,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid row: '1048583'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 5,
|
|
||||||
width: 10,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
-10,
|
|
||||||
),
|
|
||||||
Err("Invalid row: '-10'".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_parameters() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.set_user_input(0, 1, 1, "23").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_columns(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
width: 2,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
Err("Invalid parameters for autofill".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
#![allow(clippy::unwrap_used)]
|
|
||||||
|
|
||||||
use crate::constants::{LAST_COLUMN, LAST_ROW};
|
|
||||||
use crate::expressions::types::Area;
|
|
||||||
use crate::test::util::new_empty_model;
|
|
||||||
use crate::UserModel;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn basic_tests() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// This is cell A3
|
|
||||||
model.set_user_input(0, 3, 1, "alpha").unwrap();
|
|
||||||
// We autofill from A3 to A5
|
|
||||||
model
|
|
||||||
.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 3,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 4, 1),
|
|
||||||
Ok("alpha".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 5, 1),
|
|
||||||
Ok("alpha".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn one_cell_down() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.set_user_input(0, 1, 1, "23").unwrap();
|
|
||||||
model
|
|
||||||
.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 2, 1),
|
|
||||||
Ok("23".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn alpha_beta_gamma() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A1:B3
|
|
||||||
model.set_user_input(0, 1, 1, "Alpher").unwrap();
|
|
||||||
model.set_user_input(0, 2, 1, "Bethe").unwrap();
|
|
||||||
model.set_user_input(0, 3, 1, "Gamow").unwrap();
|
|
||||||
model.set_user_input(0, 1, 2, "=A1").unwrap();
|
|
||||||
model.set_user_input(0, 2, 2, "=A2").unwrap();
|
|
||||||
model.set_user_input(0, 3, 2, "=A3").unwrap();
|
|
||||||
// We autofill from A1:B3 to A9
|
|
||||||
model
|
|
||||||
.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
width: 2,
|
|
||||||
height: 3,
|
|
||||||
},
|
|
||||||
9,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 4, 1),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 5, 1),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 6, 1),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 7, 1),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 8, 1),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 9, 1),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 4, 2),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 5, 2),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 6, 2),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 7, 2),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 8, 2),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 9, 2),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(model.get_cell_content(0, 4, 2), Ok("=A4".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn styles() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A1:B3
|
|
||||||
model.set_user_input(0, 1, 1, "Alpher").unwrap();
|
|
||||||
model.set_user_input(0, 2, 1, "Bethe").unwrap();
|
|
||||||
model.set_user_input(0, 3, 1, "Gamow").unwrap();
|
|
||||||
|
|
||||||
let a2 = Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 2,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let a3 = Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 3,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
model.update_range_style(&a2, "font.i", "true").unwrap();
|
|
||||||
model
|
|
||||||
.update_range_style(&a3, "fill.bg_color", "#334455")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
model
|
|
||||||
.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 3,
|
|
||||||
},
|
|
||||||
9,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Check that cell A5 has A2 style
|
|
||||||
let style = model.get_cell_style(0, 5, 1).unwrap();
|
|
||||||
assert!(style.font.i);
|
|
||||||
// A6 would have the style of A3
|
|
||||||
let style = model.get_cell_style(0, 6, 1).unwrap();
|
|
||||||
assert_eq!(style.fill.bg_color, Some("#334455".to_string()));
|
|
||||||
|
|
||||||
model.undo().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(model.get_cell_content(0, 4, 1), Ok("".to_string()));
|
|
||||||
// Check that cell A5 has A2 style
|
|
||||||
let style = model.get_cell_style(0, 5, 1).unwrap();
|
|
||||||
assert!(!style.font.i);
|
|
||||||
// A6 would have the style of A3
|
|
||||||
let style = model.get_cell_style(0, 6, 1).unwrap();
|
|
||||||
assert_eq!(style.fill.bg_color, None);
|
|
||||||
|
|
||||||
model.redo().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 4, 1),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
// Check that cell A5 has A2 style
|
|
||||||
let style = model.get_cell_style(0, 5, 1).unwrap();
|
|
||||||
assert!(style.font.i);
|
|
||||||
// A6 would have the style of A3
|
|
||||||
let style = model.get_cell_style(0, 6, 1).unwrap();
|
|
||||||
assert_eq!(style.fill.bg_color, Some("#334455".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upwards() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A10:A12
|
|
||||||
model.set_user_input(0, 10, 1, "Alpher").unwrap();
|
|
||||||
model.set_user_input(0, 11, 1, "Bethe").unwrap();
|
|
||||||
model.set_user_input(0, 12, 1, "Gamow").unwrap();
|
|
||||||
|
|
||||||
// We fill upwards to row 5
|
|
||||||
model
|
|
||||||
.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 10,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 3,
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 9, 1),
|
|
||||||
Ok("Gamow".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 8, 1),
|
|
||||||
Ok("Bethe".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 7, 1),
|
|
||||||
Ok("Alpher".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upwards_4() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A10:A13
|
|
||||||
model.set_user_input(0, 10, 1, "Margaret Burbidge").unwrap();
|
|
||||||
model.set_user_input(0, 11, 1, "Geoffrey Burbidge").unwrap();
|
|
||||||
model.set_user_input(0, 12, 1, "Willy Fowler").unwrap();
|
|
||||||
model.set_user_input(0, 13, 1, "Fred Hoyle").unwrap();
|
|
||||||
|
|
||||||
// We fill upwards to row 5
|
|
||||||
model
|
|
||||||
.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 10,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 4,
|
|
||||||
},
|
|
||||||
5,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 9, 1),
|
|
||||||
Ok("Fred Hoyle".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 8, 1),
|
|
||||||
Ok("Willy Fowler".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_formatted_cell_value(0, 5, 1),
|
|
||||||
Ok("Fred Hoyle".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn errors() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// cells A10:A13
|
|
||||||
model.set_user_input(0, 4, 1, "Margaret Burbidge").unwrap();
|
|
||||||
|
|
||||||
// Invalid sheet
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 3,
|
|
||||||
row: 4,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid worksheet index: '3'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// invalid row
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: -1,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid row: '-1'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// invalid row
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: LAST_ROW - 1,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 10,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid row: '1048584'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: LAST_COLUMN + 1,
|
|
||||||
width: 1,
|
|
||||||
height: 10,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid column: '16385'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: LAST_COLUMN - 2,
|
|
||||||
width: 10,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
Err("Invalid column: '16391'".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 5,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 10,
|
|
||||||
},
|
|
||||||
-10,
|
|
||||||
),
|
|
||||||
Err("Invalid row: '-10'".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_parameters() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.set_user_input(0, 1, 1, "23").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
model.auto_fill_rows(
|
|
||||||
&Area {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
width: 1,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
Err("Invalid parameters for autofill".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#![allow(clippy::unwrap_used)]
|
|
||||||
|
|
||||||
use crate::test::util::new_empty_model;
|
|
||||||
use crate::UserModel;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn basic_tests() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.new_sheet();
|
|
||||||
|
|
||||||
// default sheet has show_grid_lines = true
|
|
||||||
assert_eq!(model.get_show_grid_lines(0), Ok(true));
|
|
||||||
|
|
||||||
// default new sheet has show_grid_lines = true
|
|
||||||
assert_eq!(model.get_show_grid_lines(1), Ok(true));
|
|
||||||
|
|
||||||
// wrong sheet number
|
|
||||||
assert_eq!(
|
|
||||||
model.get_show_grid_lines(2),
|
|
||||||
Err("Invalid sheet index".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// we can set it
|
|
||||||
model.set_show_grid_lines(1, false).unwrap();
|
|
||||||
assert_eq!(model.get_show_grid_lines(1), Ok(false));
|
|
||||||
assert_eq!(model.get_show_grid_lines(0), Ok(true));
|
|
||||||
|
|
||||||
model.undo().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(model.get_show_grid_lines(1), Ok(true));
|
|
||||||
assert_eq!(model.get_show_grid_lines(0), Ok(true));
|
|
||||||
|
|
||||||
model.redo().unwrap();
|
|
||||||
|
|
||||||
let send_queue = model.flush_send_queue();
|
|
||||||
let mut model2 = UserModel::from_model(new_empty_model());
|
|
||||||
model2.apply_external_diffs(&send_queue).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(model2.get_show_grid_lines(1), Ok(false));
|
|
||||||
assert_eq!(model2.get_show_grid_lines(0), Ok(true));
|
|
||||||
}
|
|
||||||
@@ -144,18 +144,13 @@ 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);
|
|
||||||
|
|
||||||
// bg_color
|
// bg_color
|
||||||
model
|
model
|
||||||
.update_range_style(&range, "fill.bg_color", "#F2F2F2")
|
.update_range_style(&range, "fill.bg_color", "#F2F2F2")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
model
|
|
||||||
.update_range_style(&range, "fill.fg_color", "#F3F4F5")
|
|
||||||
.unwrap();
|
|
||||||
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()));
|
|
||||||
|
|
||||||
let send_queue = model.flush_send_queue();
|
let send_queue = model.flush_send_queue();
|
||||||
|
|
||||||
@@ -164,7 +159,6 @@ fn basic_fill() {
|
|||||||
|
|
||||||
let style = model2.get_cell_style(0, 1, 1).unwrap();
|
let style = model2.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()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -177,15 +171,9 @@ fn fill_errors() {
|
|||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert!(model
|
||||||
model.update_range_style(&range, "fill.bg_color", "#FFF"),
|
.update_range_style(&range, "fill.bg_color", "#FFF")
|
||||||
Err("Invalid color: '#FFF'.".to_string())
|
.is_err());
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.update_range_style(&range, "fill.fg_color", "#FFF"),
|
|
||||||
Err("Invalid color: '#FFF'.".to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
#![allow(clippy::unwrap_used)]
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
constants::{LAST_COLUMN, LAST_ROW},
|
|
||||||
test::util::new_empty_model,
|
|
||||||
user_model::SelectedView,
|
|
||||||
UserModel,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn initial_view() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let model = UserModel::from_model(model);
|
|
||||||
assert_eq!(model.get_selected_sheet(), 0);
|
|
||||||
assert_eq!(model.get_selected_cell(), (0, 1, 1));
|
|
||||||
assert_eq!(
|
|
||||||
model.get_selected_view(),
|
|
||||||
SelectedView {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
range: [1, 1, 1, 1],
|
|
||||||
top_row: 1,
|
|
||||||
left_column: 1
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_the_cell_sets_the_range() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.set_selected_cell(5, 4).unwrap();
|
|
||||||
assert_eq!(model.get_selected_sheet(), 0);
|
|
||||||
assert_eq!(model.get_selected_cell(), (0, 5, 4));
|
|
||||||
assert_eq!(
|
|
||||||
model.get_selected_view(),
|
|
||||||
SelectedView {
|
|
||||||
sheet: 0,
|
|
||||||
row: 5,
|
|
||||||
column: 4,
|
|
||||||
range: [5, 4, 5, 4],
|
|
||||||
top_row: 1,
|
|
||||||
left_column: 1
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_the_range_does_not_set_the_cell() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.set_selected_range(5, 4, 10, 6).unwrap();
|
|
||||||
assert_eq!(model.get_selected_sheet(), 0);
|
|
||||||
assert_eq!(model.get_selected_cell(), (0, 1, 1));
|
|
||||||
assert_eq!(
|
|
||||||
model.get_selected_view(),
|
|
||||||
SelectedView {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
range: [5, 4, 10, 6],
|
|
||||||
top_row: 1,
|
|
||||||
left_column: 1
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add_new_sheet_and_back() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.new_sheet();
|
|
||||||
assert_eq!(model.get_selected_sheet(), 0);
|
|
||||||
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();
|
|
||||||
assert_eq!(model.get_selected_cell(), (0, 5, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_selected_cell_errors() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_selected_cell(-5, 4),
|
|
||||||
Err("Invalid row: '-5'".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_selected_cell(5, -4),
|
|
||||||
Err("Invalid column: '-4'".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_selected_range(-1, 1, 1, 1),
|
|
||||||
Err("Invalid row: '-1'".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_selected_range(1, 0, 1, 1),
|
|
||||||
Err("Invalid column: '0'".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_selected_range(1, 1, LAST_ROW + 1, 1),
|
|
||||||
Err("Invalid row: '1048577'".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_selected_range(1, 1, 1, LAST_COLUMN + 1),
|
|
||||||
Err("Invalid column: '16385'".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_selected_cell_errors_wrong_sheet() {
|
|
||||||
let mut model = new_empty_model();
|
|
||||||
// forcefully set a wrong index
|
|
||||||
model.workbook.views.get_mut(&0).unwrap().sheet = 2;
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// It's returning the wrong number
|
|
||||||
assert_eq!(model.get_selected_sheet(), 2);
|
|
||||||
|
|
||||||
// But we can't set the selected cell anymore
|
|
||||||
assert_eq!(
|
|
||||||
model.set_selected_cell(3, 4),
|
|
||||||
Err("Invalid worksheet index 2".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_selected_range(3, 4, 5, 6),
|
|
||||||
Err("Invalid worksheet index 2".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.set_top_left_visible_cell(3, 4),
|
|
||||||
Err("Invalid worksheet index 2".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// we can fix it by setting the right cell
|
|
||||||
model.set_selected_sheet(0).unwrap();
|
|
||||||
model.set_selected_cell(3, 4).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_visible_cell() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
model.set_top_left_visible_cell(100, 12).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
model.get_selected_view(),
|
|
||||||
SelectedView {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
range: [1, 1, 1, 1],
|
|
||||||
top_row: 100,
|
|
||||||
left_column: 12
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let s = serde_json::to_string(&model.get_selected_view()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<SelectedView>(&s).unwrap(),
|
|
||||||
SelectedView {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
range: [1, 1, 1, 1],
|
|
||||||
top_row: 100,
|
|
||||||
left_column: 12
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_visible_cell_errors() {
|
|
||||||
let model = new_empty_model();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_top_left_visible_cell(-100, 12),
|
|
||||||
Err("Invalid row: '-100'".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.set_top_left_visible_cell(100, -12),
|
|
||||||
Err("Invalid column: '-12'".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn errors_no_views() {
|
|
||||||
let mut model = new_empty_model();
|
|
||||||
// forcefully remove the view
|
|
||||||
model.workbook.views = HashMap::new();
|
|
||||||
// also in the sheet
|
|
||||||
model.workbook.worksheets[0].views = HashMap::new();
|
|
||||||
let mut model = UserModel::from_model(model);
|
|
||||||
// get methods will return defaults
|
|
||||||
assert_eq!(model.get_selected_sheet(), 0);
|
|
||||||
assert_eq!(model.get_selected_cell(), (0, 1, 1));
|
|
||||||
assert_eq!(
|
|
||||||
model.get_selected_view(),
|
|
||||||
SelectedView {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
range: [1, 1, 1, 1],
|
|
||||||
top_row: 1,
|
|
||||||
left_column: 1
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// set methods won't complain. but won't work either
|
|
||||||
model.set_selected_sheet(0).unwrap();
|
|
||||||
model.set_selected_cell(5, 6).unwrap();
|
|
||||||
assert_eq!(model.get_selected_cell(), (0, 1, 1));
|
|
||||||
}
|
|
||||||
@@ -4,15 +4,37 @@ use std::{collections::HashMap, fmt::Display};
|
|||||||
|
|
||||||
use crate::expressions::token::Error;
|
use crate::expressions::token::Error;
|
||||||
|
|
||||||
|
// Useful for `#[serde(default = "default_as_true")]`
|
||||||
|
fn default_as_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn default_as_false() -> bool {
|
fn default_as_false() -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Useful for `#[serde(skip_serializing_if = "is_true")]`
|
||||||
|
fn is_true(b: &bool) -> bool {
|
||||||
|
*b
|
||||||
|
}
|
||||||
|
|
||||||
fn is_false(b: &bool) -> bool {
|
fn is_false(b: &bool) -> bool {
|
||||||
!*b
|
!*b
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
fn is_zero(num: &i32) -> bool {
|
||||||
|
*num == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_default_alignment(o: &Option<Alignment>) -> bool {
|
||||||
|
o.is_none() || *o == Some(Alignment::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hashmap_is_empty(h: &HashMap<String, Table>) -> bool {
|
||||||
|
h.values().len() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct Metadata {
|
pub struct Metadata {
|
||||||
pub application: String,
|
pub application: String,
|
||||||
pub app_version: String,
|
pub app_version: String,
|
||||||
@@ -22,21 +44,14 @@ pub struct Metadata {
|
|||||||
pub last_modified: String, //"2020-11-20T16:24:35"
|
pub last_modified: String, //"2020-11-20T16:24:35"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct WorkbookSettings {
|
pub struct WorkbookSettings {
|
||||||
pub tz: String,
|
pub tz: String,
|
||||||
pub locale: String,
|
pub locale: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Workbook View tracks of the selected sheet for each view
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Clone)]
|
|
||||||
pub struct WorkbookView {
|
|
||||||
/// The index of the currently selected sheet.
|
|
||||||
pub sheet: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An internal representation of an IronCalc Workbook
|
/// An internal representation of an IronCalc Workbook
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Clone)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Workbook {
|
pub struct Workbook {
|
||||||
pub shared_strings: Vec<String>,
|
pub shared_strings: Vec<String>,
|
||||||
pub defined_names: Vec<DefinedName>,
|
pub defined_names: Vec<DefinedName>,
|
||||||
@@ -45,22 +60,28 @@ pub struct Workbook {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub settings: WorkbookSettings,
|
pub settings: WorkbookSettings,
|
||||||
pub metadata: Metadata,
|
pub metadata: Metadata,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(skip_serializing_if = "hashmap_is_empty")]
|
||||||
pub tables: HashMap<String, Table>,
|
pub tables: HashMap<String, Table>,
|
||||||
pub views: HashMap<u32, WorkbookView>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A defined name. The `sheet_id` is the sheet index in case the name is local
|
/// A defined name. The `sheet_id` is the sheet index in case the name is local
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct DefinedName {
|
pub struct DefinedName {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub formula: String,
|
pub formula: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub sheet_id: Option<u32>,
|
pub sheet_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Move to worksheet.rs make frozen_rows/columns private and u32
|
||||||
|
/// Internal representation of a worksheet Excel object
|
||||||
|
|
||||||
/// * state:
|
/// * state:
|
||||||
/// 18.18.68 ST_SheetState (Sheet Visibility Types)
|
/// 18.18.68 ST_SheetState (Sheet Visibility Types)
|
||||||
/// hidden, veryHidden, visible
|
/// hidden, veryHidden, visible
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum SheetState {
|
pub enum SheetState {
|
||||||
Visible,
|
Visible,
|
||||||
Hidden,
|
Hidden,
|
||||||
@@ -77,25 +98,8 @@ impl Display for SheetState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the state of the worksheet as seen by the user. This includes
|
|
||||||
/// details such as the currently selected cell, the visible range, and the
|
|
||||||
/// position of the viewport.
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Clone)]
|
|
||||||
pub struct WorksheetView {
|
|
||||||
/// The row index of the currently selected cell.
|
|
||||||
pub row: i32,
|
|
||||||
/// The column index of the currently selected cell.
|
|
||||||
pub column: i32,
|
|
||||||
/// The selected range in the worksheet, specified as [start_row, start_column, end_row, end_column].
|
|
||||||
pub range: [i32; 4],
|
|
||||||
/// The row index of the topmost visible cell in the worksheet view.
|
|
||||||
pub top_row: i32,
|
|
||||||
/// The column index of the leftmost visible cell in the worksheet view.
|
|
||||||
pub left_column: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal representation of a worksheet Excel object
|
/// Internal representation of a worksheet Excel object
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Clone)]
|
||||||
pub struct Worksheet {
|
pub struct Worksheet {
|
||||||
pub dimension: String,
|
pub dimension: String,
|
||||||
pub cols: Vec<Col>,
|
pub cols: Vec<Col>,
|
||||||
@@ -105,14 +109,16 @@ pub struct Worksheet {
|
|||||||
pub shared_formulas: Vec<String>,
|
pub shared_formulas: Vec<String>,
|
||||||
pub sheet_id: u32,
|
pub sheet_id: u32,
|
||||||
pub state: SheetState,
|
pub state: SheetState,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub merge_cells: Vec<String>,
|
pub merge_cells: Vec<String>,
|
||||||
pub comments: Vec<Comment>,
|
pub comments: Vec<Comment>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(skip_serializing_if = "is_zero")]
|
||||||
pub frozen_rows: i32,
|
pub frozen_rows: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(skip_serializing_if = "is_zero")]
|
||||||
pub frozen_columns: i32,
|
pub frozen_columns: i32,
|
||||||
pub views: HashMap<u32, WorksheetView>,
|
|
||||||
/// Whether or not to show the grid lines in the worksheet
|
|
||||||
pub show_grid_lines: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal representation of Excel's sheet_data
|
/// Internal representation of Excel's sheet_data
|
||||||
@@ -120,7 +126,7 @@ pub struct Worksheet {
|
|||||||
pub type SheetData = HashMap<i32, HashMap<i32, Cell>>;
|
pub type SheetData = HashMap<i32, HashMap<i32, Cell>>;
|
||||||
|
|
||||||
// ECMA-376-1:2016 section 18.3.1.73
|
// ECMA-376-1:2016 section 18.3.1.73
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Clone)]
|
||||||
pub struct Row {
|
pub struct Row {
|
||||||
/// Row index
|
/// Row index
|
||||||
pub r: i32,
|
pub r: i32,
|
||||||
@@ -128,19 +134,23 @@ pub struct Row {
|
|||||||
pub custom_format: bool,
|
pub custom_format: bool,
|
||||||
pub custom_height: bool,
|
pub custom_height: bool,
|
||||||
pub s: i32,
|
pub s: i32,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub hidden: bool,
|
pub hidden: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ECMA-376-1:2016 section 18.3.1.13
|
// ECMA-376-1:2016 section 18.3.1.13
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Clone)]
|
||||||
pub struct Col {
|
pub struct Col {
|
||||||
// Column definitions are defined on ranges, unlike rows which store unique, per-row entries.
|
// Column definitions are defined on ranges, unlike rows which store unique, per-row entries.
|
||||||
/// First column affected by this record. Settings apply to column in \[min, max\] range.
|
/// First column affected by this record. Settings apply to column in \[min, max\] range.
|
||||||
pub min: i32,
|
pub min: i32,
|
||||||
/// Last column affected by this record. Settings apply to column in \[min, max\] range.
|
/// Last column affected by this record. Settings apply to column in \[min, max\] range.
|
||||||
pub max: i32,
|
pub max: i32,
|
||||||
|
|
||||||
pub width: f64,
|
pub width: f64,
|
||||||
pub custom_width: bool,
|
pub custom_width: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub style: Option<i32>,
|
pub style: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,55 +165,32 @@ pub enum CellType {
|
|||||||
CompoundData = 128,
|
CompoundData = 128,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, Clone, PartialEq)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, Clone, PartialEq)]
|
||||||
|
#[serde(tag = "t", deny_unknown_fields)]
|
||||||
pub enum Cell {
|
pub enum Cell {
|
||||||
EmptyCell {
|
#[serde(rename = "empty")]
|
||||||
s: i32,
|
EmptyCell { s: i32 },
|
||||||
},
|
#[serde(rename = "b")]
|
||||||
|
BooleanCell { v: bool, s: i32 },
|
||||||
BooleanCell {
|
#[serde(rename = "n")]
|
||||||
v: bool,
|
NumberCell { v: f64, s: i32 },
|
||||||
s: i32,
|
|
||||||
},
|
|
||||||
|
|
||||||
NumberCell {
|
|
||||||
v: f64,
|
|
||||||
s: i32,
|
|
||||||
},
|
|
||||||
// Maybe we should not have this type. In Excel this is just a string
|
// Maybe we should not have this type. In Excel this is just a string
|
||||||
ErrorCell {
|
#[serde(rename = "e")]
|
||||||
ei: Error,
|
ErrorCell { ei: Error, s: i32 },
|
||||||
s: i32,
|
|
||||||
},
|
|
||||||
// Always a shared string
|
// Always a shared string
|
||||||
SharedString {
|
#[serde(rename = "s")]
|
||||||
si: i32,
|
SharedString { si: i32, s: i32 },
|
||||||
s: i32,
|
|
||||||
},
|
|
||||||
// Non evaluated Formula
|
// Non evaluated Formula
|
||||||
CellFormula {
|
#[serde(rename = "u")]
|
||||||
f: i32,
|
CellFormula { f: i32, s: i32 },
|
||||||
s: i32,
|
#[serde(rename = "fb")]
|
||||||
},
|
CellFormulaBoolean { f: i32, v: bool, s: i32 },
|
||||||
|
#[serde(rename = "fn")]
|
||||||
CellFormulaBoolean {
|
CellFormulaNumber { f: i32, v: f64, s: i32 },
|
||||||
f: i32,
|
|
||||||
v: bool,
|
|
||||||
s: i32,
|
|
||||||
},
|
|
||||||
|
|
||||||
CellFormulaNumber {
|
|
||||||
f: i32,
|
|
||||||
v: f64,
|
|
||||||
s: i32,
|
|
||||||
},
|
|
||||||
// always inline string
|
// always inline string
|
||||||
CellFormulaString {
|
#[serde(rename = "str")]
|
||||||
f: i32,
|
CellFormulaString { f: i32, v: String, s: i32 },
|
||||||
v: String,
|
#[serde(rename = "fe")]
|
||||||
s: i32,
|
|
||||||
},
|
|
||||||
|
|
||||||
CellFormulaError {
|
CellFormulaError {
|
||||||
f: i32,
|
f: i32,
|
||||||
ei: Error,
|
ei: Error,
|
||||||
@@ -222,16 +209,17 @@ impl Default for Cell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub author_name: String,
|
pub author_name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub author_id: Option<String>,
|
pub author_id: Option<String>,
|
||||||
pub cell_ref: String,
|
pub cell_ref: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ECMA-376-1:2016 section 18.5.1.2
|
// ECMA-376-1:2016 section 18.5.1.2
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct Table {
|
pub struct Table {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
@@ -239,24 +227,34 @@ pub struct Table {
|
|||||||
pub reference: String,
|
pub reference: String,
|
||||||
pub totals_row_count: u32,
|
pub totals_row_count: u32,
|
||||||
pub header_row_count: u32,
|
pub header_row_count: u32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub header_row_dxf_id: Option<u32>,
|
pub header_row_dxf_id: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub data_dxf_id: Option<u32>,
|
pub data_dxf_id: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub totals_row_dxf_id: Option<u32>,
|
pub totals_row_dxf_id: Option<u32>,
|
||||||
pub columns: Vec<TableColumn>,
|
pub columns: Vec<TableColumn>,
|
||||||
pub style_info: TableStyleInfo,
|
pub style_info: TableStyleInfo,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub has_filters: bool,
|
pub has_filters: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// totals_row_label vs totals_row_function might be mutually exclusive. Use an enum?
|
// totals_row_label vs totals_row_function might be mutually exclusive. Use an enum?
|
||||||
// the totals_row_function is an enum not String methinks
|
// the totals_row_function is an enum not String methinks
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct TableColumn {
|
pub struct TableColumn {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub totals_row_label: Option<String>,
|
pub totals_row_label: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub header_row_dxf_id: Option<u32>,
|
pub header_row_dxf_id: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub data_dxf_id: Option<u32>,
|
pub data_dxf_id: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub totals_row_dxf_id: Option<u32>,
|
pub totals_row_dxf_id: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub totals_row_function: Option<String>,
|
pub totals_row_function: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,16 +272,25 @@ impl Default for TableColumn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Default)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct TableStyleInfo {
|
pub struct TableStyleInfo {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub show_first_column: bool,
|
pub show_first_column: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub show_last_column: bool,
|
pub show_last_column: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub show_row_stripes: bool,
|
pub show_row_stripes: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub show_column_stripes: bool,
|
pub show_column_stripes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct Styles {
|
pub struct Styles {
|
||||||
pub num_fmts: Vec<NumFmt>,
|
pub num_fmts: Vec<NumFmt>,
|
||||||
pub fonts: Vec<Font>,
|
pub fonts: Vec<Font>,
|
||||||
@@ -319,7 +326,7 @@ pub struct Style {
|
|||||||
pub quote_prefix: bool,
|
pub quote_prefix: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct NumFmt {
|
pub struct NumFmt {
|
||||||
pub num_fmt_id: i32,
|
pub num_fmt_id: i32,
|
||||||
pub format_code: String,
|
pub format_code: String,
|
||||||
@@ -509,17 +516,29 @@ pub struct Alignment {
|
|||||||
pub wrap_text: bool,
|
pub wrap_text: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct CellStyleXfs {
|
pub struct CellStyleXfs {
|
||||||
pub num_fmt_id: i32,
|
pub num_fmt_id: i32,
|
||||||
pub font_id: i32,
|
pub font_id: i32,
|
||||||
pub fill_id: i32,
|
pub fill_id: i32,
|
||||||
pub border_id: i32,
|
pub border_id: i32,
|
||||||
|
#[serde(default = "default_as_true")]
|
||||||
|
#[serde(skip_serializing_if = "is_true")]
|
||||||
pub apply_number_format: bool,
|
pub apply_number_format: bool,
|
||||||
|
#[serde(default = "default_as_true")]
|
||||||
|
#[serde(skip_serializing_if = "is_true")]
|
||||||
pub apply_border: bool,
|
pub apply_border: bool,
|
||||||
|
#[serde(default = "default_as_true")]
|
||||||
|
#[serde(skip_serializing_if = "is_true")]
|
||||||
pub apply_alignment: bool,
|
pub apply_alignment: bool,
|
||||||
|
#[serde(default = "default_as_true")]
|
||||||
|
#[serde(skip_serializing_if = "is_true")]
|
||||||
pub apply_protection: bool,
|
pub apply_protection: bool,
|
||||||
|
#[serde(default = "default_as_true")]
|
||||||
|
#[serde(skip_serializing_if = "is_true")]
|
||||||
pub apply_font: bool,
|
pub apply_font: bool,
|
||||||
|
#[serde(default = "default_as_true")]
|
||||||
|
#[serde(skip_serializing_if = "is_true")]
|
||||||
pub apply_fill: bool,
|
pub apply_fill: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,24 +559,39 @@ impl Default for CellStyleXfs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone, Default)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct CellXfs {
|
pub struct CellXfs {
|
||||||
pub xf_id: i32,
|
pub xf_id: i32,
|
||||||
pub num_fmt_id: i32,
|
pub num_fmt_id: i32,
|
||||||
pub font_id: i32,
|
pub font_id: i32,
|
||||||
pub fill_id: i32,
|
pub fill_id: i32,
|
||||||
pub border_id: i32,
|
pub border_id: i32,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub apply_number_format: bool,
|
pub apply_number_format: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub apply_border: bool,
|
pub apply_border: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub apply_alignment: bool,
|
pub apply_alignment: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub apply_protection: bool,
|
pub apply_protection: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub apply_font: bool,
|
pub apply_font: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub apply_fill: bool,
|
pub apply_fill: bool,
|
||||||
|
#[serde(default = "default_as_false")]
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
pub quote_prefix: bool,
|
pub quote_prefix: bool,
|
||||||
|
#[serde(skip_serializing_if = "is_default_alignment")]
|
||||||
pub alignment: Option<Alignment>,
|
pub alignment: Option<Alignment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
#[derive(Serialize, Deserialize, Encode, Decode, Debug, PartialEq, Eq, Clone)]
|
||||||
pub struct CellStyles {
|
pub struct CellStyles {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub xf_id: i32,
|
pub xf_id: i32,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
use std::{collections::HashMap, fmt::Debug};
|
use std::{collections::HashMap, fmt::Debug};
|
||||||
|
|
||||||
use bitcode::{Decode, Encode};
|
use bitcode::{Decode, Encode};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants,
|
constants,
|
||||||
@@ -19,17 +18,6 @@ use crate::{
|
|||||||
utils::is_valid_hex_color,
|
utils::is_valid_hex_color,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
#[cfg_attr(test, derive(PartialEq, Debug))]
|
|
||||||
pub struct SelectedView {
|
|
||||||
pub sheet: u32,
|
|
||||||
pub row: i32,
|
|
||||||
pub column: i32,
|
|
||||||
pub range: [i32; 4],
|
|
||||||
pub top_row: i32,
|
|
||||||
pub left_column: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Encode, Decode)]
|
#[derive(Clone, Encode, Decode)]
|
||||||
struct RowData {
|
struct RowData {
|
||||||
row: Option<Row>,
|
row: Option<Row>,
|
||||||
@@ -130,11 +118,6 @@ enum Diff {
|
|||||||
old_value: String,
|
old_value: String,
|
||||||
new_value: String,
|
new_value: String,
|
||||||
},
|
},
|
||||||
SetShowGridLines {
|
|
||||||
sheet: u32,
|
|
||||||
old_value: bool,
|
|
||||||
new_value: bool,
|
|
||||||
}, // FIXME: we are missing SetViewDiffs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiffList = Vec<Diff>;
|
type DiffList = Vec<Diff>;
|
||||||
@@ -266,7 +249,7 @@ fn vertical(value: &str) -> Result<VerticalAlignment, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// # A wrapper around [`Model`] for a spreadsheet end user.
|
/// # A wrapper around [`Model`] for a spreadsheet end user.
|
||||||
/// UserModel is a wrapper around Model with undo/redo history, _diffs_, automatic evaluation and view management.
|
/// UserModel is a wrapper around Model with undo/redo history, _diffs_ and automatic evaluation.
|
||||||
///
|
///
|
||||||
/// A diff in this context (or more correctly a _user diff_) is a change created by a user.
|
/// A diff in this context (or more correctly a _user diff_) is a change created by a user.
|
||||||
///
|
///
|
||||||
@@ -292,10 +275,7 @@ fn vertical(value: &str) -> Result<VerticalAlignment, String> {
|
|||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
pub struct UserModel {
|
pub struct UserModel {
|
||||||
/// The underlying model
|
model: Model,
|
||||||
/// See also:
|
|
||||||
/// * [Model]
|
|
||||||
pub model: Model,
|
|
||||||
history: History,
|
history: History,
|
||||||
send_queue: Vec<QueueDiffs>,
|
send_queue: Vec<QueueDiffs>,
|
||||||
pause_evaluation: bool,
|
pause_evaluation: bool,
|
||||||
@@ -648,9 +628,7 @@ impl UserModel {
|
|||||||
pub fn insert_row(&mut self, sheet: u32, row: i32) -> Result<(), String> {
|
pub fn insert_row(&mut self, sheet: u32, row: i32) -> Result<(), String> {
|
||||||
let diff_list = vec![Diff::InsertRow { sheet, row }];
|
let diff_list = vec![Diff::InsertRow { sheet, row }];
|
||||||
self.push_diff_list(diff_list);
|
self.push_diff_list(diff_list);
|
||||||
self.model.insert_rows(sheet, row, 1)?;
|
self.model.insert_rows(sheet, row, 1)
|
||||||
self.model.evaluate();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a row
|
/// Deletes a row
|
||||||
@@ -677,9 +655,7 @@ impl UserModel {
|
|||||||
old_data,
|
old_data,
|
||||||
}];
|
}];
|
||||||
self.push_diff_list(diff_list);
|
self.push_diff_list(diff_list);
|
||||||
self.model.delete_rows(sheet, row, 1)?;
|
self.model.delete_rows(sheet, row, 1)
|
||||||
self.model.evaluate();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts a column
|
/// Inserts a column
|
||||||
@@ -689,9 +665,7 @@ impl UserModel {
|
|||||||
pub fn insert_column(&mut self, sheet: u32, column: i32) -> Result<(), String> {
|
pub fn insert_column(&mut self, sheet: u32, column: i32) -> Result<(), String> {
|
||||||
let diff_list = vec![Diff::InsertColumn { sheet, column }];
|
let diff_list = vec![Diff::InsertColumn { sheet, column }];
|
||||||
self.push_diff_list(diff_list);
|
self.push_diff_list(diff_list);
|
||||||
self.model.insert_columns(sheet, column, 1)?;
|
self.model.insert_columns(sheet, column, 1)
|
||||||
self.model.evaluate();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a column
|
/// Deletes a column
|
||||||
@@ -736,9 +710,7 @@ impl UserModel {
|
|||||||
}),
|
}),
|
||||||
}];
|
}];
|
||||||
self.push_diff_list(diff_list);
|
self.push_diff_list(diff_list);
|
||||||
self.model.delete_columns(sheet, column, 1)?;
|
self.model.delete_columns(sheet, column, 1)
|
||||||
self.model.evaluate();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the width of a column
|
/// Sets the width of a column
|
||||||
@@ -877,7 +849,7 @@ impl UserModel {
|
|||||||
style.fill.fg_color = color(value)?;
|
style.fill.fg_color = color(value)?;
|
||||||
}
|
}
|
||||||
"num_fmt" => {
|
"num_fmt" => {
|
||||||
value.clone_into(&mut style.num_fmt);
|
style.num_fmt = value.to_owned();
|
||||||
}
|
}
|
||||||
"border.left" => {
|
"border.left" => {
|
||||||
style.border.left = border(value)?;
|
style.border.left = border(value)?;
|
||||||
@@ -938,7 +910,7 @@ impl UserModel {
|
|||||||
column,
|
column,
|
||||||
old_value: Box::new(old_value),
|
old_value: Box::new(old_value),
|
||||||
new_value: Box::new(style),
|
new_value: Box::new(style),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.push_diff_list(diff_list);
|
self.push_diff_list(diff_list);
|
||||||
@@ -954,208 +926,6 @@ impl UserModel {
|
|||||||
Ok(self.model.get_style_for_cell(sheet, row, column))
|
Ok(self.model.get_style_for_cell(sheet, row, column))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fills the cells from `source_area` until `to_row`.
|
|
||||||
/// This simulates the user clicking on the cell outline handle and dragging it downwards (or upwards)
|
|
||||||
pub fn auto_fill_rows(&mut self, source_area: &Area, to_row: i32) -> Result<(), String> {
|
|
||||||
let mut diff_list = Vec::new();
|
|
||||||
let sheet = source_area.sheet;
|
|
||||||
let row1 = source_area.row;
|
|
||||||
let column1 = source_area.column;
|
|
||||||
let width1 = source_area.width;
|
|
||||||
let height1 = source_area.height;
|
|
||||||
|
|
||||||
// Check first all parameters are valid
|
|
||||||
if self.model.workbook.worksheet(sheet).is_err() {
|
|
||||||
return Err(format!("Invalid worksheet index: '{sheet}'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_valid_column_number(column1) {
|
|
||||||
return Err(format!("Invalid column: '{column1}'"));
|
|
||||||
}
|
|
||||||
if !is_valid_row(row1) {
|
|
||||||
return Err(format!("Invalid row: '{row1}'"));
|
|
||||||
}
|
|
||||||
if !is_valid_column_number(column1 + width1 - 1) {
|
|
||||||
return Err(format!("Invalid column: '{}'", column1 + width1 - 1));
|
|
||||||
}
|
|
||||||
if !is_valid_row(row1 + height1 - 1) {
|
|
||||||
return Err(format!("Invalid row: '{}'", row1 + height1 - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_valid_row(to_row) {
|
|
||||||
return Err(format!("Invalid row: '{to_row}'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// anchor_row is the first row that repeats in each case.
|
|
||||||
let anchor_row;
|
|
||||||
let sign;
|
|
||||||
// this is the range of rows we are going to fill
|
|
||||||
let row_range: Vec<i32>;
|
|
||||||
|
|
||||||
if to_row >= row1 + height1 {
|
|
||||||
// we go downwards, we start from `row1 + height1` to `to_row`,
|
|
||||||
anchor_row = row1;
|
|
||||||
sign = 1;
|
|
||||||
row_range = (row1 + height1..to_row + 1).collect();
|
|
||||||
} else if to_row < row1 {
|
|
||||||
// we go upwards, starting from `row1 - `` all the way to `to_row`
|
|
||||||
anchor_row = row1 + height1 - 1;
|
|
||||||
sign = -1;
|
|
||||||
row_range = (to_row..row1).rev().collect();
|
|
||||||
} else {
|
|
||||||
return Err("Invalid parameters for autofill".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
for column in column1..column1 + width1 {
|
|
||||||
let mut index = 0;
|
|
||||||
for row_ref in &row_range {
|
|
||||||
// Save value and style first
|
|
||||||
let row = *row_ref;
|
|
||||||
let old_value = self
|
|
||||||
.model
|
|
||||||
.workbook
|
|
||||||
.worksheet(sheet)?
|
|
||||||
.cell(row, column)
|
|
||||||
.cloned();
|
|
||||||
let old_style = self.model.get_style_for_cell(sheet, row, column);
|
|
||||||
|
|
||||||
// compute the new value and set it
|
|
||||||
let source_row = anchor_row + index;
|
|
||||||
let target_value = self
|
|
||||||
.model
|
|
||||||
.extend_to(sheet, source_row, column, row, column)?;
|
|
||||||
self.model
|
|
||||||
.set_user_input(sheet, row, column, target_value.to_string());
|
|
||||||
|
|
||||||
// Compute the new style and set it
|
|
||||||
let new_style = self.model.get_style_for_cell(sheet, source_row, column);
|
|
||||||
self.model.set_cell_style(sheet, row, column, &new_style)?;
|
|
||||||
|
|
||||||
// Add the diffs
|
|
||||||
diff_list.push(Diff::SetCellStyle {
|
|
||||||
sheet,
|
|
||||||
row,
|
|
||||||
column,
|
|
||||||
old_value: Box::new(old_style),
|
|
||||||
new_value: Box::new(new_style),
|
|
||||||
});
|
|
||||||
diff_list.push(Diff::SetCellValue {
|
|
||||||
sheet,
|
|
||||||
row,
|
|
||||||
column,
|
|
||||||
new_value: target_value.to_string(),
|
|
||||||
old_value: Box::new(old_value),
|
|
||||||
});
|
|
||||||
|
|
||||||
index = (index + sign) % height1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.push_diff_list(diff_list);
|
|
||||||
self.evaluate();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fills the cells from `source_area` until `to_column`.
|
|
||||||
/// This simulates the user clicking on the cell outline handle and dragging it to the right (or to the left)
|
|
||||||
pub fn auto_fill_columns(&mut self, source_area: &Area, to_column: i32) -> Result<(), String> {
|
|
||||||
let mut diff_list = Vec::new();
|
|
||||||
let sheet = source_area.sheet;
|
|
||||||
let row1 = source_area.row;
|
|
||||||
let column1 = source_area.column;
|
|
||||||
let width1 = source_area.width;
|
|
||||||
let height1 = source_area.height;
|
|
||||||
|
|
||||||
// Check first all parameters are valid
|
|
||||||
if self.model.workbook.worksheet(sheet).is_err() {
|
|
||||||
return Err(format!("Invalid worksheet index: '{sheet}'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_valid_column_number(column1) {
|
|
||||||
return Err(format!("Invalid column: '{column1}'"));
|
|
||||||
}
|
|
||||||
if !is_valid_row(row1) {
|
|
||||||
return Err(format!("Invalid row: '{row1}'"));
|
|
||||||
}
|
|
||||||
if !is_valid_column_number(column1 + width1 - 1) {
|
|
||||||
return Err(format!("Invalid column: '{}'", column1 + width1 - 1));
|
|
||||||
}
|
|
||||||
if !is_valid_row(row1 + height1 - 1) {
|
|
||||||
return Err(format!("Invalid row: '{}'", row1 + height1 - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_valid_row(to_column) {
|
|
||||||
return Err(format!("Invalid row: '{to_column}'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// anchor_column is the first column that repeats in each case.
|
|
||||||
let anchor_column;
|
|
||||||
let sign;
|
|
||||||
// this is the range of columns we are going to fill
|
|
||||||
let column_range: Vec<i32>;
|
|
||||||
|
|
||||||
if to_column >= column1 + width1 {
|
|
||||||
// we go right, we start from `1 + width` to `to_column`,
|
|
||||||
anchor_column = column1;
|
|
||||||
sign = 1;
|
|
||||||
column_range = (column1 + width1..to_column + 1).collect();
|
|
||||||
} else if to_column < column1 {
|
|
||||||
// we go left, starting from `column1 - `` all the way to `to_column`
|
|
||||||
anchor_column = column1 + width1 - 1;
|
|
||||||
sign = -1;
|
|
||||||
column_range = (to_column..column1).rev().collect();
|
|
||||||
} else {
|
|
||||||
return Err("Invalid parameters for autofill".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
for row in row1..row1 + height1 {
|
|
||||||
let mut index = 0;
|
|
||||||
for column_ref in &column_range {
|
|
||||||
let column = *column_ref;
|
|
||||||
// Save value and style first
|
|
||||||
let old_value = self
|
|
||||||
.model
|
|
||||||
.workbook
|
|
||||||
.worksheet(sheet)?
|
|
||||||
.cell(row, column)
|
|
||||||
.cloned();
|
|
||||||
let old_style = self.model.get_style_for_cell(sheet, row, column);
|
|
||||||
|
|
||||||
// compute the new value and set it
|
|
||||||
let source_column = anchor_column + index;
|
|
||||||
let target_value = self
|
|
||||||
.model
|
|
||||||
.extend_to(sheet, row, source_column, row, column)?;
|
|
||||||
self.model
|
|
||||||
.set_user_input(sheet, row, column, target_value.to_string());
|
|
||||||
|
|
||||||
// Compute the new style and set it
|
|
||||||
let new_style = self.model.get_style_for_cell(sheet, row, source_column);
|
|
||||||
self.model.set_cell_style(sheet, row, column, &new_style)?;
|
|
||||||
|
|
||||||
// Add the diffs
|
|
||||||
diff_list.push(Diff::SetCellStyle {
|
|
||||||
sheet,
|
|
||||||
row,
|
|
||||||
column,
|
|
||||||
old_value: Box::new(old_style),
|
|
||||||
new_value: Box::new(new_style),
|
|
||||||
});
|
|
||||||
diff_list.push(Diff::SetCellValue {
|
|
||||||
sheet,
|
|
||||||
row,
|
|
||||||
column,
|
|
||||||
new_value: target_value.to_string(),
|
|
||||||
old_value: Box::new(old_value),
|
|
||||||
});
|
|
||||||
|
|
||||||
index = (index + sign) % width1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.push_diff_list(diff_list);
|
|
||||||
self.evaluate();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns information about the sheets
|
/// Returns information about the sheets
|
||||||
///
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
@@ -1165,184 +935,6 @@ impl UserModel {
|
|||||||
self.model.get_worksheets_properties()
|
self.model.get_worksheets_properties()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the selected sheet index
|
|
||||||
pub fn get_selected_sheet(&self) -> u32 {
|
|
||||||
if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
|
|
||||||
view.sheet
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the selected cell
|
|
||||||
pub fn get_selected_cell(&self) -> (u32, i32, i32) {
|
|
||||||
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
|
|
||||||
view.sheet
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
if let Ok(worksheet) = self.model.workbook.worksheet(sheet) {
|
|
||||||
if let Some(view) = worksheet.views.get(&self.model.view_id) {
|
|
||||||
return (sheet, view.row, view.column);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// return a safe default
|
|
||||||
(0, 1, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns selected view
|
|
||||||
pub fn get_selected_view(&self) -> SelectedView {
|
|
||||||
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
|
|
||||||
view.sheet
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
if let Ok(worksheet) = self.model.workbook.worksheet(sheet) {
|
|
||||||
if let Some(view) = worksheet.views.get(&self.model.view_id) {
|
|
||||||
return SelectedView {
|
|
||||||
sheet,
|
|
||||||
row: view.row,
|
|
||||||
column: view.column,
|
|
||||||
range: view.range,
|
|
||||||
top_row: view.top_row,
|
|
||||||
left_column: view.left_column,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// return a safe default
|
|
||||||
SelectedView {
|
|
||||||
sheet: 0,
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
range: [1, 1, 1, 1],
|
|
||||||
top_row: 1,
|
|
||||||
left_column: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the the selected sheet
|
|
||||||
pub fn set_selected_sheet(&mut self, sheet: u32) -> Result<(), String> {
|
|
||||||
if self.model.workbook.worksheet(sheet).is_err() {
|
|
||||||
return Err(format!("Invalid worksheet index {}", sheet));
|
|
||||||
}
|
|
||||||
if let Some(view) = self.model.workbook.views.get_mut(&0) {
|
|
||||||
view.sheet = sheet;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the selected cell
|
|
||||||
pub fn set_selected_cell(&mut self, row: i32, column: i32) -> Result<(), String> {
|
|
||||||
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
|
|
||||||
view.sheet
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
if !is_valid_column_number(column) {
|
|
||||||
return Err(format!("Invalid column: '{column}'"));
|
|
||||||
}
|
|
||||||
if !is_valid_row(row) {
|
|
||||||
return Err(format!("Invalid row: '{row}'"));
|
|
||||||
}
|
|
||||||
if self.model.workbook.worksheet(sheet).is_err() {
|
|
||||||
return Err(format!("Invalid worksheet index {}", sheet));
|
|
||||||
}
|
|
||||||
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
|
|
||||||
if let Some(view) = worksheet.views.get_mut(&0) {
|
|
||||||
view.row = row;
|
|
||||||
view.column = column;
|
|
||||||
view.range = [row, column, row, column];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the selected range
|
|
||||||
pub fn set_selected_range(
|
|
||||||
&mut self,
|
|
||||||
start_row: i32,
|
|
||||||
start_column: i32,
|
|
||||||
end_row: i32,
|
|
||||||
end_column: i32,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
|
|
||||||
view.sheet
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
if !is_valid_column_number(start_column) {
|
|
||||||
return Err(format!("Invalid column: '{start_column}'"));
|
|
||||||
}
|
|
||||||
if !is_valid_column_number(start_row) {
|
|
||||||
return Err(format!("Invalid row: '{start_row}'"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_valid_column_number(end_column) {
|
|
||||||
return Err(format!("Invalid column: '{end_column}'"));
|
|
||||||
}
|
|
||||||
if !is_valid_column_number(end_row) {
|
|
||||||
return Err(format!("Invalid row: '{end_row}'"));
|
|
||||||
}
|
|
||||||
if self.model.workbook.worksheet(sheet).is_err() {
|
|
||||||
return Err(format!("Invalid worksheet index {}", sheet));
|
|
||||||
}
|
|
||||||
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
|
|
||||||
if let Some(view) = worksheet.views.get_mut(&0) {
|
|
||||||
view.range = [start_row, start_column, end_row, end_column];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the value of the first visible cell
|
|
||||||
pub fn set_top_left_visible_cell(
|
|
||||||
&mut self,
|
|
||||||
top_row: i32,
|
|
||||||
left_column: i32,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let sheet = if let Some(view) = self.model.workbook.views.get(&self.model.view_id) {
|
|
||||||
view.sheet
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
if !is_valid_column_number(left_column) {
|
|
||||||
return Err(format!("Invalid column: '{left_column}'"));
|
|
||||||
}
|
|
||||||
if !is_valid_column_number(top_row) {
|
|
||||||
return Err(format!("Invalid row: '{top_row}'"));
|
|
||||||
}
|
|
||||||
if self.model.workbook.worksheet(sheet).is_err() {
|
|
||||||
return Err(format!("Invalid worksheet index {}", sheet));
|
|
||||||
}
|
|
||||||
if let Ok(worksheet) = self.model.workbook.worksheet_mut(sheet) {
|
|
||||||
if let Some(view) = worksheet.views.get_mut(&0) {
|
|
||||||
view.top_row = top_row;
|
|
||||||
view.left_column = left_column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the gid lines in the worksheet to visible (`true`) or hidden (`false`)
|
|
||||||
pub fn set_show_grid_lines(&mut self, sheet: u32, show_grid_lines: bool) -> Result<(), String> {
|
|
||||||
let old_value = self.model.workbook.worksheet(sheet)?.show_grid_lines;
|
|
||||||
self.model.set_show_grid_lines(sheet, show_grid_lines)?;
|
|
||||||
|
|
||||||
self.push_diff_list(vec![Diff::SetShowGridLines {
|
|
||||||
sheet,
|
|
||||||
new_value: show_grid_lines,
|
|
||||||
old_value,
|
|
||||||
}]);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true in the grid lines for
|
|
||||||
pub fn get_show_grid_lines(&self, sheet: u32) -> Result<bool, String> {
|
|
||||||
Ok(self.model.workbook.worksheet(sheet)?.show_grid_lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// **** Private methods ****** //
|
// **** Private methods ****** //
|
||||||
|
|
||||||
fn push_diff_list(&mut self, diff_list: DiffList) {
|
fn push_diff_list(&mut self, diff_list: DiffList) {
|
||||||
@@ -1506,13 +1098,6 @@ impl UserModel {
|
|||||||
} => {
|
} => {
|
||||||
self.model.set_sheet_color(*index, old_value)?;
|
self.model.set_sheet_color(*index, old_value)?;
|
||||||
}
|
}
|
||||||
Diff::SetShowGridLines {
|
|
||||||
sheet,
|
|
||||||
old_value,
|
|
||||||
new_value: _,
|
|
||||||
} => {
|
|
||||||
self.model.set_show_grid_lines(*sheet, *old_value)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if needs_evaluation {
|
if needs_evaluation {
|
||||||
@@ -1633,13 +1218,6 @@ impl UserModel {
|
|||||||
} => {
|
} => {
|
||||||
self.model.set_sheet_color(*index, new_value)?;
|
self.model.set_sheet_color(*index, new_value)?;
|
||||||
}
|
}
|
||||||
Diff::SetShowGridLines {
|
|
||||||
sheet,
|
|
||||||
old_value: _,
|
|
||||||
new_value,
|
|
||||||
} => {
|
|
||||||
self.model.set_show_grid_lines(*sheet, *new_value)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,65 +63,15 @@ style_types = r"""
|
|||||||
getCellStyle(sheet: number, row: number, column: number): CellStyle;
|
getCellStyle(sheet: number, row: number, column: number): CellStyle;
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
view = r"""
|
|
||||||
* @returns {any}
|
|
||||||
*/
|
|
||||||
getSelectedView(): any;
|
|
||||||
""".strip()
|
|
||||||
|
|
||||||
view_types = r"""
|
|
||||||
* @returns {CellStyle}
|
|
||||||
*/
|
|
||||||
getSelectedView(): SelectedView;
|
|
||||||
""".strip()
|
|
||||||
|
|
||||||
autofill_rows = r"""
|
|
||||||
/**
|
|
||||||
* @param {any} source_area
|
|
||||||
* @param {number} to_row
|
|
||||||
*/
|
|
||||||
autoFillRows(source_area: any, to_row: number): void;
|
|
||||||
"""
|
|
||||||
|
|
||||||
autofill_rows_types = r"""
|
|
||||||
/**
|
|
||||||
* @param {Area} source_area
|
|
||||||
* @param {number} to_row
|
|
||||||
*/
|
|
||||||
autoFillRows(source_area: Area, to_row: number): void;
|
|
||||||
"""
|
|
||||||
|
|
||||||
autofill_columns = r"""
|
|
||||||
/**
|
|
||||||
* @param {any} source_area
|
|
||||||
* @param {number} to_column
|
|
||||||
*/
|
|
||||||
autoFillColumns(source_area: any, to_column: number): void;
|
|
||||||
"""
|
|
||||||
|
|
||||||
autofill_columns_types = r"""
|
|
||||||
/**
|
|
||||||
* @param {Area} source_area
|
|
||||||
* @param {number} to_column
|
|
||||||
*/
|
|
||||||
autoFillColumns(source_area: Area, to_column: number): 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)
|
||||||
text = text.replace(properties, properties_types)
|
text = text.replace(properties, properties_types)
|
||||||
text = text.replace(style, style_types)
|
text = text.replace(style, style_types)
|
||||||
text = text.replace(view, view_types)
|
|
||||||
text = text.replace(autofill_rows, autofill_rows_types)
|
|
||||||
text = text.replace(autofill_columns, autofill_columns_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)
|
||||||
text = text.replace(header, header_types)
|
text = text.replace(header, header_types)
|
||||||
if text.find("any") != -1:
|
|
||||||
print("There are 'unfixed' types. Please check.")
|
|
||||||
exit(1)
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -286,94 +286,4 @@ impl Model {
|
|||||||
pub fn get_worksheets_properties(&self) -> JsValue {
|
pub fn get_worksheets_properties(&self) -> JsValue {
|
||||||
serde_wasm_bindgen::to_value(&self.model.get_worksheets_properties()).unwrap()
|
serde_wasm_bindgen::to_value(&self.model.get_worksheets_properties()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "getSelectedSheet")]
|
|
||||||
pub fn get_selected_sheet(&self) -> u32 {
|
|
||||||
self.model.get_selected_sheet()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "getSelectedCell")]
|
|
||||||
pub fn get_selected_cell(&self) -> Vec<i32> {
|
|
||||||
let (sheet, row, column) = self.model.get_selected_cell();
|
|
||||||
vec![sheet as i32, row, column]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "getSelectedView")]
|
|
||||||
pub fn get_selected_view(&self) -> JsValue {
|
|
||||||
serde_wasm_bindgen::to_value(&self.model.get_selected_view()).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "setSelectedSheet")]
|
|
||||||
pub fn set_selected_sheet(&mut self, sheet: u32) -> Result<(), JsError> {
|
|
||||||
self.model.set_selected_sheet(sheet).map_err(to_js_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "setSelectedCell")]
|
|
||||||
pub fn set_selected_cell(&mut self, row: i32, column: i32) -> Result<(), JsError> {
|
|
||||||
self.model
|
|
||||||
.set_selected_cell(row, column)
|
|
||||||
.map_err(to_js_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "setSelectedRange")]
|
|
||||||
pub fn set_selected_range(
|
|
||||||
&mut self,
|
|
||||||
start_row: i32,
|
|
||||||
start_column: i32,
|
|
||||||
end_row: i32,
|
|
||||||
end_column: i32,
|
|
||||||
) -> Result<(), JsError> {
|
|
||||||
self.model
|
|
||||||
.set_selected_range(start_row, start_column, end_row, end_column)
|
|
||||||
.map_err(to_js_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "setTopLeftVisibleCell")]
|
|
||||||
pub fn set_top_left_visible_cell(
|
|
||||||
&mut self,
|
|
||||||
top_row: i32,
|
|
||||||
top_column: i32,
|
|
||||||
) -> Result<(), JsError> {
|
|
||||||
self.model
|
|
||||||
.set_top_left_visible_cell(top_row, top_column)
|
|
||||||
.map_err(to_js_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "setShowGridLines")]
|
|
||||||
pub fn set_show_grid_lines(
|
|
||||||
&mut self,
|
|
||||||
sheet: u32,
|
|
||||||
show_grid_lines: bool,
|
|
||||||
) -> Result<(), JsError> {
|
|
||||||
self.model
|
|
||||||
.set_show_grid_lines(sheet, show_grid_lines)
|
|
||||||
.map_err(to_js_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "getShowGridLines")]
|
|
||||||
pub fn get_show_grid_lines(&mut self, sheet: u32) -> Result<bool, JsError> {
|
|
||||||
self.model.get_show_grid_lines(sheet).map_err(to_js_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "autoFillRows")]
|
|
||||||
pub fn auto_fill_rows(&mut self, source_area: JsValue, to_row: i32) -> Result<(), JsError> {
|
|
||||||
let area: Area =
|
|
||||||
serde_wasm_bindgen::from_value(source_area).map_err(|e| to_js_error(e.to_string()))?;
|
|
||||||
self.model
|
|
||||||
.auto_fill_rows(&area, to_row)
|
|
||||||
.map_err(to_js_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = "autoFillColumns")]
|
|
||||||
pub fn auto_fill_columns(
|
|
||||||
&mut self,
|
|
||||||
source_area: JsValue,
|
|
||||||
to_column: i32,
|
|
||||||
) -> Result<(), JsError> {
|
|
||||||
let area: Area =
|
|
||||||
serde_wasm_bindgen::from_value(source_area).map_err(|e| to_js_error(e.to_string()))?;
|
|
||||||
self.model
|
|
||||||
.auto_fill_columns(&area, to_column)
|
|
||||||
.map_err(to_js_error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,14 +119,5 @@ test("floating column numbers get truncated", () => {
|
|||||||
assert.strictEqual(model.getRowHeight(0, 5), 100.5);
|
assert.strictEqual(model.getRowHeight(0, 5), 100.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("autofill", () => {
|
|
||||||
const model = new Model('en', 'UTC');
|
|
||||||
model.setUserInput(0, 1, 1, "23");
|
|
||||||
model.autoFillRows({sheet: 0, row: 1, column: 1, width: 1, height: 1}, 2);
|
|
||||||
|
|
||||||
const result = model.getFormattedCellValue(0, 2, 1);
|
|
||||||
assert.strictEqual(result, "23");
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "tiron"
|
|
||||||
version = "0.1.3"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
crossterm = "0.27.0"
|
|
||||||
ironcalc = { path = "../xlsx"}
|
|
||||||
ratatui = "0.26.2"
|
|
||||||
tui-input = "0.8.0"
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# TironCalc
|
|
||||||
|
|
||||||
[![Discord chat][discord-badge]][discord-url]
|
|
||||||
|
|
||||||
[discord-badge]: https://img.shields.io/discord/1206947691058171904.svg?logo=discord&style=flat-square
|
|
||||||
[discord-url]: https://discord.gg/zZYWfh3RHJ
|
|
||||||
|
|
||||||
TironCalc, or Tiron for friends, is a TUI (Terminal User Interface) for IronCalc. Based on [ratatui](https://github.com/ratatui-org/ratatui)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
You will find the binary at `./target/release/tiron`.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Start empty project:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ tiron
|
|
||||||
```
|
|
||||||
|
|
||||||
Load an existing Excel file:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ tiron example.xlsx
|
|
||||||
```
|
|
||||||
|
|
||||||
- `e` to edit a cell and enter the value or formula.
|
|
||||||
- `q` to quit and save
|
|
||||||
- `+` to add a sheet
|
|
||||||
- `s` to go to the next sheet
|
|
||||||
- `PgUp/PgDown` to navigate rows faster
|
|
||||||
- `u` undo changes
|
|
||||||
- `U` redo changes
|
|
||||||
- `r` insert row
|
|
||||||
- `c` insert column
|
|
||||||
- `C` delete column
|
|
||||||
- `R` delete row
|
|
||||||
- `
|
|
||||||
|
|
||||||
|
|
||||||
## Inspiration
|
|
||||||
|
|
||||||
James Gosling of Java fame created [sc](https://en.wikipedia.org/wiki/Sc_(spreadsheet_calculator)) the spreadsheet calculator.
|
|
||||||
|
|
||||||
Andrés Martinelli has been maintaining [sc-im](https://github.com/andmarti1424/sc-im), the spreadsheet calculator improvised.
|
|
||||||
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,556 +0,0 @@
|
|||||||
use crossterm::{
|
|
||||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, KeyCode},
|
|
||||||
execute,
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
};
|
|
||||||
use ironcalc::{
|
|
||||||
base::{expressions::utils::number_to_column, Model, UserModel},
|
|
||||||
export::save_to_xlsx,
|
|
||||||
import::{load_from_icalc, load_from_xlsx},
|
|
||||||
};
|
|
||||||
use ratatui::{
|
|
||||||
backend::CrosstermBackend,
|
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
|
||||||
style::{Color, Style, Stylize},
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Table},
|
|
||||||
Terminal,
|
|
||||||
};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use std::{io, sync::mpsc};
|
|
||||||
use tui_input::{backend::crossterm::EventHandler, Input};
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
enum Event<I> {
|
|
||||||
Input(I),
|
|
||||||
Tick,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
|
||||||
enum CursorMode {
|
|
||||||
Navigate,
|
|
||||||
Input,
|
|
||||||
Popup,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SelectedRange {
|
|
||||||
sheet: u32,
|
|
||||||
row: i32,
|
|
||||||
column: i32,
|
|
||||||
min_row: i32,
|
|
||||||
min_column: i32,
|
|
||||||
max_row: i32,
|
|
||||||
max_column: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SheetState {
|
|
||||||
row: i32,
|
|
||||||
column: i32,
|
|
||||||
min_row: i32,
|
|
||||||
min_column: i32,
|
|
||||||
max_row: i32,
|
|
||||||
max_column: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ModelState {
|
|
||||||
selected_sheet: u32,
|
|
||||||
sheet_states: Vec<SheetState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelState {
|
|
||||||
pub fn new(sheet_count: usize) -> ModelState {
|
|
||||||
let mut sheet_states = vec![];
|
|
||||||
for _ in 0..sheet_count {
|
|
||||||
sheet_states.push(SheetState {
|
|
||||||
row: 1,
|
|
||||||
column: 1,
|
|
||||||
min_row: 1,
|
|
||||||
min_column: 1,
|
|
||||||
max_row: 1,
|
|
||||||
max_column: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ModelState {
|
|
||||||
selected_sheet: 0,
|
|
||||||
sheet_states,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_selected_range(&self) -> SelectedRange {
|
|
||||||
let sheet = self.selected_sheet;
|
|
||||||
let sheet_state = self.sheet_states.get(sheet as usize).unwrap();
|
|
||||||
|
|
||||||
SelectedRange {
|
|
||||||
sheet,
|
|
||||||
row: sheet_state.row,
|
|
||||||
column: sheet_state.column,
|
|
||||||
min_column: sheet_state.min_column,
|
|
||||||
min_row: sheet_state.min_row,
|
|
||||||
max_column: sheet_state.max_column,
|
|
||||||
max_row: sheet_state.max_row,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_selected_sheet(&mut self, selected_sheet: u32) {
|
|
||||||
self.selected_sheet = selected_sheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_selected_sheet(&self) -> u32 {
|
|
||||||
self.selected_sheet
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_up(&mut self) {
|
|
||||||
let sheet = self.selected_sheet;
|
|
||||||
let mut sheet_state = &mut self.sheet_states.get(sheet as usize).unwrap();
|
|
||||||
sheet_state.column -= 1;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_down(&mut self) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_left(&mut self) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_right(&mut self) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_shift_up(&mut self) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_shift_down(&mut self) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_shift_left(&mut self) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_shift_right(&mut self) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
enable_raw_mode()?;
|
|
||||||
|
|
||||||
let args: Vec<String> = env::args().collect();
|
|
||||||
let mut file_name = "model.xlsx";
|
|
||||||
let model = if args.len() > 1 {
|
|
||||||
file_name = &args[1];
|
|
||||||
if file_name.ends_with(".ic") {
|
|
||||||
load_from_icalc(file_name).unwrap()
|
|
||||||
} else {
|
|
||||||
load_from_xlsx(file_name, "en", "UTC").unwrap()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Model::new_empty(file_name, "en", "UTC").unwrap()
|
|
||||||
};
|
|
||||||
let mut user_model = UserModel::from_model(model);
|
|
||||||
let mut state = ModelState::new(user_model.get_worksheets_properties().len());
|
|
||||||
// let mut selected_sheet = 0;
|
|
||||||
// let mut selected_row_index = 1;
|
|
||||||
// let mut selected_column_index = 1;
|
|
||||||
let mut minimum_row_index = 1;
|
|
||||||
let mut minimum_column_index = 1;
|
|
||||||
let sheet_list_width = 20;
|
|
||||||
let column_width: u16 = 11;
|
|
||||||
let mut cursor_mode = CursorMode::Navigate;
|
|
||||||
let mut input_formula = Input::default();
|
|
||||||
|
|
||||||
let mut input_file_name: Input = file_name.into();
|
|
||||||
|
|
||||||
let mut popup_open = false;
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
let tick_rate = Duration::from_millis(200);
|
|
||||||
thread::spawn(move || {
|
|
||||||
let mut last_tick = Instant::now();
|
|
||||||
loop {
|
|
||||||
let timeout = tick_rate
|
|
||||||
.checked_sub(last_tick.elapsed())
|
|
||||||
.unwrap_or_else(|| Duration::from_secs(0));
|
|
||||||
|
|
||||||
if event::poll(timeout).expect("poll works") {
|
|
||||||
if let CEvent::Key(key) = event::read().expect("can read events") {
|
|
||||||
tx.send(Event::Input(key)).expect("can send events");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if last_tick.elapsed() >= tick_rate && tx.send(Event::Tick).is_ok() {
|
|
||||||
last_tick = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let mut terminal = Terminal::new(backend)?;
|
|
||||||
terminal.clear()?;
|
|
||||||
|
|
||||||
let header_style = Style::default().fg(Color::Yellow).bg(Color::White);
|
|
||||||
let selected_header_style = Style::default().bg(Color::Yellow).fg(Color::White);
|
|
||||||
|
|
||||||
let selected_cell_style = Style::default().fg(Color::Yellow).bg(Color::LightCyan);
|
|
||||||
|
|
||||||
let background_style = Style::default().bg(Color::Black);
|
|
||||||
let selected_sheet_style = Style::default().bg(Color::White).fg(Color::LightMagenta);
|
|
||||||
let non_selected_sheet_style = Style::default().fg(Color::White);
|
|
||||||
let mut sheet_properties = user_model.get_worksheets_properties();
|
|
||||||
loop {
|
|
||||||
terminal.draw(|rect| {
|
|
||||||
let size = rect.size();
|
|
||||||
|
|
||||||
let global_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Length(sheet_list_width), Constraint::Min(3)].as_ref())
|
|
||||||
.split(size);
|
|
||||||
|
|
||||||
// Sheet list to the left
|
|
||||||
let sheets = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.style(Style::default().fg(Color::White))
|
|
||||||
.title("Sheets")
|
|
||||||
.border_type(BorderType::Plain)
|
|
||||||
.style(background_style);
|
|
||||||
let mut rows = vec![];
|
|
||||||
(0..sheet_properties.len()).for_each(|sheet_index| {
|
|
||||||
let sheet_name = &sheet_properties[sheet_index].name;
|
|
||||||
let style = if sheet_index == state.get_selected_sheet() {
|
|
||||||
selected_sheet_style
|
|
||||||
} else {
|
|
||||||
non_selected_sheet_style
|
|
||||||
};
|
|
||||||
rows.push(Row::new(vec![Cell::from(sheet_name.clone()).style(style)]));
|
|
||||||
});
|
|
||||||
let widths = &[Constraint::Length(100)];
|
|
||||||
let sheet_list = Table::new(rows, widths).block(sheets).column_spacing(0);
|
|
||||||
|
|
||||||
rect.render_widget(sheet_list, global_chunks[0]);
|
|
||||||
|
|
||||||
// The spreadsheet is the formula bar at the top and the sheet data
|
|
||||||
let spreadsheet_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.margin(0)
|
|
||||||
.constraints([Constraint::Length(1), Constraint::Min(2)].as_ref())
|
|
||||||
.split(global_chunks[1]);
|
|
||||||
|
|
||||||
let spreadsheet_width = size.width - sheet_list_width;
|
|
||||||
let spreadsheet_heigh = size.height - 1;
|
|
||||||
let row_count = spreadsheet_heigh - 1;
|
|
||||||
|
|
||||||
let first_row_width: u16 = 3;
|
|
||||||
let column_count =
|
|
||||||
f64::ceil(((spreadsheet_width - first_row_width) as f64) / (column_width as f64))
|
|
||||||
as i32;
|
|
||||||
let mut rows = vec![];
|
|
||||||
// The first row in the column headers
|
|
||||||
let mut row = Vec::new();
|
|
||||||
// The first cell in that row is the top left square of the spreadsheet
|
|
||||||
row.push(Cell::from(""));
|
|
||||||
let mut maximum_column_index = minimum_column_index + column_count - 1;
|
|
||||||
let mut maximum_row_index = minimum_row_index + row_count - 1;
|
|
||||||
|
|
||||||
// We want to make sure the selected cell is visible.
|
|
||||||
if selected_column_index > maximum_column_index {
|
|
||||||
maximum_column_index = selected_column_index;
|
|
||||||
minimum_column_index = maximum_column_index - column_count + 1;
|
|
||||||
} else if selected_column_index < minimum_column_index {
|
|
||||||
minimum_column_index = selected_column_index;
|
|
||||||
maximum_column_index = minimum_column_index + column_count - 1;
|
|
||||||
}
|
|
||||||
if selected_row_index >= maximum_row_index {
|
|
||||||
maximum_row_index = selected_row_index;
|
|
||||||
minimum_row_index = maximum_row_index - row_count + 1;
|
|
||||||
} else if selected_row_index < minimum_row_index {
|
|
||||||
minimum_row_index = selected_row_index;
|
|
||||||
maximum_row_index = minimum_row_index + row_count - 1;
|
|
||||||
}
|
|
||||||
for column_index in minimum_column_index..=maximum_column_index {
|
|
||||||
let column_str = number_to_column(column_index);
|
|
||||||
let style = if column_index == selected_column_index {
|
|
||||||
selected_header_style
|
|
||||||
} else {
|
|
||||||
header_style
|
|
||||||
};
|
|
||||||
row.push(Cell::from(format!(" {}", column_str.unwrap())).style(style));
|
|
||||||
}
|
|
||||||
rows.push(Row::new(row));
|
|
||||||
for row_index in minimum_row_index..=maximum_row_index {
|
|
||||||
let mut row = Vec::new();
|
|
||||||
let style = if row_index == selected_row_index {
|
|
||||||
selected_header_style
|
|
||||||
} else {
|
|
||||||
header_style
|
|
||||||
};
|
|
||||||
row.push(Cell::from(format!("{}", row_index)).style(style));
|
|
||||||
for column_index in minimum_column_index..=maximum_column_index {
|
|
||||||
let value = user_model
|
|
||||||
.get_formatted_cell_value(
|
|
||||||
selected_sheet as u32,
|
|
||||||
row_index as i32,
|
|
||||||
column_index,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// let cell_style = user_model
|
|
||||||
// .get_cell_style(selected_sheet as u32, row_index as i32, column_index)
|
|
||||||
// .unwrap();
|
|
||||||
let style = if selected_row_index == row_index
|
|
||||||
&& selected_column_index == column_index
|
|
||||||
{
|
|
||||||
selected_cell_style
|
|
||||||
} else {
|
|
||||||
// let bg_color = match cell_style.fill.fg_color {
|
|
||||||
// Some(s) => Color::from_str(&s).unwrap(),
|
|
||||||
// None => Color::White,
|
|
||||||
// };
|
|
||||||
// let fg_color = match cell_style.font.color {
|
|
||||||
// Some(s) => Color::from_str(&s).unwrap(),
|
|
||||||
// None => Color::Black,
|
|
||||||
// };
|
|
||||||
let bg_color = Color::White;
|
|
||||||
let fg_color = Color::Black;
|
|
||||||
Style::default().fg(fg_color).bg(bg_color)
|
|
||||||
};
|
|
||||||
row.push(Cell::from(value.to_string()).style(style));
|
|
||||||
}
|
|
||||||
rows.push(Row::new(row));
|
|
||||||
}
|
|
||||||
let mut widths = Vec::new();
|
|
||||||
widths.push(Constraint::Length(first_row_width));
|
|
||||||
for _ in 0..column_count {
|
|
||||||
widths.push(Constraint::Length(column_width));
|
|
||||||
}
|
|
||||||
let spreadsheet = Table::new(rows, widths)
|
|
||||||
.block(Block::default().style(Style::default().bg(Color::Black)))
|
|
||||||
.column_spacing(0);
|
|
||||||
|
|
||||||
let text = if cursor_mode != CursorMode::Input {
|
|
||||||
user_model
|
|
||||||
.get_cell_content(
|
|
||||||
selected_sheet as u32,
|
|
||||||
selected_row_index as i32,
|
|
||||||
selected_column_index,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
} else {
|
|
||||||
input_formula.value().to_string()
|
|
||||||
};
|
|
||||||
let cell_address_text = format!(
|
|
||||||
"{}{}: ",
|
|
||||||
number_to_column(selected_column_index).unwrap(),
|
|
||||||
selected_row_index,
|
|
||||||
);
|
|
||||||
let formula_bar_text = format!("{}{}", cell_address_text, text,);
|
|
||||||
let formula_bar = Paragraph::new(vec![Line::from(vec![Span::raw(formula_bar_text)])]);
|
|
||||||
rect.render_widget(formula_bar.block(Block::default()), spreadsheet_chunks[0]);
|
|
||||||
rect.render_widget(spreadsheet, spreadsheet_chunks[1]);
|
|
||||||
if cursor_mode == CursorMode::Input {
|
|
||||||
let area = spreadsheet_chunks[0];
|
|
||||||
rect.set_cursor(
|
|
||||||
area.x
|
|
||||||
+ (input_formula.visual_cursor() as u16)
|
|
||||||
+ cell_address_text.len() as u16,
|
|
||||||
area.y,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if popup_open {
|
|
||||||
let area = centered_rect(60, 20, size);
|
|
||||||
rect.render_widget(Clear, area);
|
|
||||||
let input_text = input_file_name.value();
|
|
||||||
let text = vec![
|
|
||||||
Line::from(vec![input_text.fg(Color::Yellow)]),
|
|
||||||
"".into(),
|
|
||||||
Line::from(vec![
|
|
||||||
"ESC".green(),
|
|
||||||
" to abort. ".into(),
|
|
||||||
"END".green(),
|
|
||||||
" to quit without saving. ".into(),
|
|
||||||
"Enter".green(),
|
|
||||||
" to save and quit".into(),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
rect.render_widget(
|
|
||||||
Paragraph::new(text).block(Block::bordered().title("Save as")),
|
|
||||||
area,
|
|
||||||
);
|
|
||||||
rect.set_cursor(
|
|
||||||
// Put cursor past the end of the input text
|
|
||||||
area.x + (input_file_name.visual_cursor() as u16) + 1,
|
|
||||||
// Move one line own, from the border to the input line
|
|
||||||
area.y + 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match cursor_mode {
|
|
||||||
CursorMode::Popup => {
|
|
||||||
match rx.recv()? {
|
|
||||||
Event::Input(event) => match event.code {
|
|
||||||
KeyCode::End => {
|
|
||||||
terminal.clear()?;
|
|
||||||
// restore terminal
|
|
||||||
disable_raw_mode()?;
|
|
||||||
execute!(
|
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture
|
|
||||||
)?;
|
|
||||||
terminal.show_cursor()?;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
terminal.clear()?;
|
|
||||||
// restore terminal
|
|
||||||
disable_raw_mode()?;
|
|
||||||
execute!(
|
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture
|
|
||||||
)?;
|
|
||||||
terminal.show_cursor()?;
|
|
||||||
let _ = save_to_xlsx(&user_model.model, input_file_name.value());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
popup_open = false;
|
|
||||||
cursor_mode = CursorMode::Navigate;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
input_file_name.handle_event(&CEvent::Key(event));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Event::Tick => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CursorMode::Navigate => {
|
|
||||||
match rx.recv()? {
|
|
||||||
Event::Input(event) => match event.code {
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
popup_open = true;
|
|
||||||
cursor_mode = CursorMode::Popup;
|
|
||||||
}
|
|
||||||
KeyCode::Down => {
|
|
||||||
selected_row_index += 1;
|
|
||||||
}
|
|
||||||
KeyCode::Up => {
|
|
||||||
if selected_row_index > 1 {
|
|
||||||
selected_row_index -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
|
||||||
selected_column_index += 1;
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
|
||||||
if selected_column_index > 1 {
|
|
||||||
selected_column_index -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::PageDown => {
|
|
||||||
selected_row_index += 10;
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => {
|
|
||||||
if selected_row_index > 10 {
|
|
||||||
selected_row_index -= 10;
|
|
||||||
} else {
|
|
||||||
selected_row_index = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('s') => {
|
|
||||||
selected_sheet += 1;
|
|
||||||
if selected_sheet >= sheet_properties.len() {
|
|
||||||
selected_sheet = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('a') => {
|
|
||||||
selected_sheet = selected_sheet.saturating_sub(1);
|
|
||||||
}
|
|
||||||
KeyCode::Char('u') => user_model.undo().unwrap(),
|
|
||||||
KeyCode::Char('U') => user_model.redo().unwrap(),
|
|
||||||
KeyCode::Char('c') => user_model
|
|
||||||
.insert_column(selected_sheet as u32, selected_column_index as i32)
|
|
||||||
.unwrap(),
|
|
||||||
KeyCode::Char('C') => user_model
|
|
||||||
.delete_column(selected_sheet as u32, selected_column_index as i32)
|
|
||||||
.unwrap(),
|
|
||||||
KeyCode::Char('r') => user_model
|
|
||||||
.insert_row(selected_sheet as u32, selected_row_index as i32)
|
|
||||||
.unwrap(),
|
|
||||||
KeyCode::Char('R') => user_model
|
|
||||||
.delete_row(selected_sheet as u32, selected_row_index as i32)
|
|
||||||
.unwrap(),
|
|
||||||
KeyCode::Char('e') => {
|
|
||||||
cursor_mode = CursorMode::Input;
|
|
||||||
let input_str = user_model
|
|
||||||
.get_cell_content(
|
|
||||||
selected_sheet as u32,
|
|
||||||
selected_row_index as i32,
|
|
||||||
selected_column_index,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// .unwrap_or_default();
|
|
||||||
input_formula = input_formula.with_value(input_str);
|
|
||||||
}
|
|
||||||
KeyCode::Char('+') => {
|
|
||||||
user_model.new_sheet();
|
|
||||||
sheet_properties = user_model.get_worksheets_properties();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// println!("{:?}", event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Event::Tick => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CursorMode::Input => match rx.recv()? {
|
|
||||||
Event::Input(event) => match event.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
cursor_mode = CursorMode::Navigate;
|
|
||||||
let value = input_formula.value().to_string();
|
|
||||||
let sheet = selected_sheet as i32;
|
|
||||||
let row = selected_row_index as i32;
|
|
||||||
let column = selected_column_index;
|
|
||||||
user_model
|
|
||||||
.set_user_input(sheet as u32, row, column, &value)
|
|
||||||
.unwrap();
|
|
||||||
user_model.evaluate();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
input_formula.handle_event(&CEvent::Key(event));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Event::Tick => {}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper function to create a centered rect using up certain percentage of the available rect `r`
|
|
||||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
|
||||||
let popup_layout = Layout::vertical([
|
|
||||||
Constraint::Percentage((100 - percent_y) / 2),
|
|
||||||
Constraint::Percentage(percent_y),
|
|
||||||
Constraint::Percentage((100 - percent_y) / 2),
|
|
||||||
])
|
|
||||||
.split(r);
|
|
||||||
|
|
||||||
Layout::horizontal([
|
|
||||||
Constraint::Percentage((100 - percent_x) / 2),
|
|
||||||
Constraint::Percentage(percent_x),
|
|
||||||
Constraint::Percentage((100 - percent_x) / 2),
|
|
||||||
])
|
|
||||||
.split(popup_layout[1])[1]
|
|
||||||
}
|
|
||||||
3
webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/*
|
||||||
|
dist/*
|
||||||
|
example.json
|
||||||
19
webapp/.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-onboarding',
|
||||||
|
'@storybook/addon-interactions',
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/react-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
29
webapp/.storybook/preview.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Preview } from '@storybook/react';
|
||||||
|
import i18n from '../src/i18n';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
|
||||||
|
const withI18next = (Story: any) => {
|
||||||
|
return (
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<Story />
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const decorators = [withI18next];
|
||||||
|
export default preview;
|
||||||
21
webapp/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# IronCalc Web App
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
BIN
webapp/example.xlsx
Normal file
16
webapp/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- <meta name="theme-color" content="#1bb566"> -->
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2994A" />
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" />
|
||||||
|
<title>Spreadsheet</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
webapp/jest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Config } from "jest";
|
||||||
|
// import {defaults} from 'jest-config';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
// testMatch:["**.jest.mjs"],
|
||||||
|
moduleFileExtensions: ["js", "ts", "mts", "mjs"],
|
||||||
|
transform: {
|
||||||
|
"^.+\\.[jt]s?$": "ts-jest",
|
||||||
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@ironcalc/wasm$": "<rootDir>/node_modules/@ironcalc/nodejs/"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
17153
webapp/package-lock.json
generated
Normal file
55
webapp/package.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"restore": "cp node_modules/@ironcalc/wasm/wasm_bg.wasm node_modules/.vite/deps/",
|
||||||
|
"dev": "vite",
|
||||||
|
"test": "jest",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.4",
|
||||||
|
"@emotion/styled": "^11.11.5",
|
||||||
|
"@ironcalc/wasm": "file:../bindings/wasm/pkg",
|
||||||
|
"@mui/material": "^5.15.15",
|
||||||
|
"i18next": "^23.11.1",
|
||||||
|
"lucide-react": "^0.292.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-i18next": "^13.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@storybook/addon-essentials": "^7.6.17",
|
||||||
|
"@storybook/addon-interactions": "^7.6.17",
|
||||||
|
"@storybook/addon-links": "^7.6.17",
|
||||||
|
"@storybook/addon-onboarding": "^1.0.11",
|
||||||
|
"@storybook/blocks": "^7.5.3",
|
||||||
|
"@storybook/react": "^7.5.3",
|
||||||
|
"@storybook/react-vite": "^7.6.17",
|
||||||
|
"@storybook/testing-library": "^0.2.2",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/react": "^18.2.75",
|
||||||
|
"@types/react-dom": "^18.2.24",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
|
"eslint-plugin-storybook": "^0.6.15",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"storybook": "^7.6.17",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.2.8",
|
||||||
|
"vite-plugin-svgr": "^4.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
webapp/src/App.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#root {
|
||||||
|
position: absolute;
|
||||||
|
inset: 10px;
|
||||||
|
border: 1px solid #AAA;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
40
webapp/src/App.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import "./App.css";
|
||||||
|
import Workbook from "./components/workbook";
|
||||||
|
import "./i18n";
|
||||||
|
import { createContext, useEffect, useState } from "react";
|
||||||
|
import init, { Model } from "@ironcalc/wasm";
|
||||||
|
import { WorkbookState } from "./components/workbookState";
|
||||||
|
import WorkbookContext from "./components/workbookContext";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [model, setModel] = useState<Model | null>(null);
|
||||||
|
const [workbookState, setWorkbookState] = useState<WorkbookState | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
async function start() {
|
||||||
|
await init();
|
||||||
|
const model_bytes = new Uint8Array(await (await fetch("./example.ic")).arrayBuffer());
|
||||||
|
const _model = Model.from_bytes(model_bytes);
|
||||||
|
// const _model = new Model("en", "UTC");
|
||||||
|
if (!model) setModel(_model);
|
||||||
|
if (!workbookState) setWorkbookState(new WorkbookState());
|
||||||
|
}
|
||||||
|
start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!model || !workbookState) {
|
||||||
|
return <div>Loading</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
return (
|
||||||
|
// <WorkbookContext.Provider value={{}}>
|
||||||
|
<Workbook model={model} workbookState={workbookState} />
|
||||||
|
// </WorkbookContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
12
webapp/src/components/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Keyboard and mouse events architecture
|
||||||
|
|
||||||
|
This document describes the architecture of the keyboard navigation and mouse events in IronCalc Web
|
||||||
|
|
||||||
|
There are two modes for mouse events:
|
||||||
|
|
||||||
|
* Normal mode: clicking a cell selects it, clicking on a sheet opens it
|
||||||
|
* Browse mode: clicking on a cell updates the formula, etc
|
||||||
|
|
||||||
|
While in browse mode some mouse events might end the browse mode
|
||||||
|
|
||||||
|
We follow Excel's way of navigating a spreadsheet
|
||||||
25
webapp/src/components/WorksheetCanvas/constants.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export const headerCornerBackground = '#FFF';
|
||||||
|
export const headerTextColor = '#333';
|
||||||
|
export const headerBackground = '#FFF';
|
||||||
|
export const headerGlobalSelectorColor = '#EAECF4';
|
||||||
|
export const headerSelectedBackground = '#EEEEEE';
|
||||||
|
export const headerFullSelectedBackground = '#D3D6E9';
|
||||||
|
export const headerSelectedColor = '#333';
|
||||||
|
export const headerBorderColor = '#DEE0EF';
|
||||||
|
|
||||||
|
export const gridColor = '#D3D6E9';
|
||||||
|
export const gridSeparatorColor = '#D3D6E9';
|
||||||
|
export const defaultTextColor = '#2E414D';
|
||||||
|
|
||||||
|
export const outlineColor = '#F2994A';
|
||||||
|
export const outlineBackgroundColor = '#F2994A1A';
|
||||||
|
|
||||||
|
export const LAST_COLUMN = 16_384;
|
||||||
|
export const LAST_ROW = 1_048_576;
|
||||||
|
|
||||||
|
// FIXME: Browsers cannot have a height that big
|
||||||
|
// For now we will go A-IZ and 10_000 rows
|
||||||
|
export const lastColumn = 260; // TODO: Excel supports up to 16_384
|
||||||
|
// I know of a world with one million moons.
|
||||||
|
// Carl Sagan in The cosmic connection Chapter 7 "Space Exploration as a Human Enterprise"
|
||||||
|
export const lastRow = 10_000; // TODO: Excel supports up to 1_048_576
|
||||||
23
webapp/src/components/WorksheetCanvas/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface Cell {
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Area {
|
||||||
|
rowStart: number;
|
||||||
|
rowEnd: number;
|
||||||
|
columnStart: number;
|
||||||
|
columnEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SheetArea extends Area {
|
||||||
|
sheet: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AreaWithBorderInterface extends Area {
|
||||||
|
border: "left" | "top" | "right" | "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AreaWithBorder = AreaWithBorderInterface | null;
|
||||||
|
|
||||||
396
webapp/src/components/WorksheetCanvas/util.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
const letters = [
|
||||||
|
'A',
|
||||||
|
'B',
|
||||||
|
'C',
|
||||||
|
'D',
|
||||||
|
'E',
|
||||||
|
'F',
|
||||||
|
'G',
|
||||||
|
'H',
|
||||||
|
'I',
|
||||||
|
'J',
|
||||||
|
'K',
|
||||||
|
'L',
|
||||||
|
'M',
|
||||||
|
'N',
|
||||||
|
'O',
|
||||||
|
'P',
|
||||||
|
'Q',
|
||||||
|
'R',
|
||||||
|
'S',
|
||||||
|
'T',
|
||||||
|
'U',
|
||||||
|
'V',
|
||||||
|
'W',
|
||||||
|
'X',
|
||||||
|
'Y',
|
||||||
|
'Z',
|
||||||
|
];
|
||||||
|
interface Reference {
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
absoluteRow: boolean;
|
||||||
|
absoluteColumn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function referenceToString(rf: Reference): string {
|
||||||
|
const absC = rf.absoluteColumn ? '$' : '';
|
||||||
|
const absR = rf.absoluteRow ? '$' : '';
|
||||||
|
return absC + columnNameFromNumber(rf.column) + absR + rf.row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function columnNameFromNumber(column: number): string {
|
||||||
|
let columnName = '';
|
||||||
|
let index = column;
|
||||||
|
while (index > 0) {
|
||||||
|
columnName = `${letters[(index - 1) % 26]}${columnName}`;
|
||||||
|
index = Math.floor((index - 1) / 26);
|
||||||
|
}
|
||||||
|
return columnName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function columnNumberFromName(columnName: string): number {
|
||||||
|
let column = 0;
|
||||||
|
for (const character of columnName) {
|
||||||
|
const index = (character.codePointAt(0) ?? 0) - 64;
|
||||||
|
column = column * 26 + index;
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EqualTo 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})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergedAreas(area1: Area, area2: Area): Area {
|
||||||
|
return {
|
||||||
|
rowStart: Math.min(area1.rowStart, area2.rowStart, area1.rowEnd, area2.rowEnd),
|
||||||
|
rowEnd: Math.max(area1.rowStart, area2.rowStart, area1.rowEnd, area2.rowEnd),
|
||||||
|
columnStart: Math.min(area1.columnStart, area2.columnStart, area1.columnEnd, area2.columnEnd),
|
||||||
|
columnEnd: Math.max(area1.columnStart, area2.columnStart, area1.columnEnd, area2.columnEnd),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpandToArea(area: Area, cell: Cell): AreaWithBorder {
|
||||||
|
let { rowStart, rowEnd, columnStart, columnEnd } = area;
|
||||||
|
if (rowStart > rowEnd) {
|
||||||
|
[rowStart, rowEnd] = [rowEnd, rowStart];
|
||||||
|
}
|
||||||
|
if (columnStart > columnEnd) {
|
||||||
|
[columnStart, columnEnd] = [columnEnd, columnStart];
|
||||||
|
}
|
||||||
|
const { row, column } = cell;
|
||||||
|
if (row <= rowEnd && row >= rowStart && column >= columnStart && column <= columnEnd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Two rules:
|
||||||
|
// * The extendTo area must be larger than the selected area
|
||||||
|
// * The extendTo area must be of the same width or the same height as the selected area
|
||||||
|
if (row >= rowEnd && column >= columnStart) {
|
||||||
|
// Normal case: we are expanding down and right
|
||||||
|
if (row - rowEnd > column - columnEnd) {
|
||||||
|
// Expanding by rows (down)
|
||||||
|
return {
|
||||||
|
rowStart: rowEnd + 1,
|
||||||
|
rowEnd: row,
|
||||||
|
columnStart,
|
||||||
|
columnEnd,
|
||||||
|
border: 'top',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// expanding by columns (right)
|
||||||
|
return {
|
||||||
|
rowStart,
|
||||||
|
rowEnd,
|
||||||
|
columnStart: columnEnd + 1,
|
||||||
|
columnEnd: column,
|
||||||
|
border: 'left',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row >= rowEnd && column <= columnStart) {
|
||||||
|
// We are expanding down and left
|
||||||
|
if (row - rowEnd > columnStart - column) {
|
||||||
|
// Expanding by rows (down)
|
||||||
|
return {
|
||||||
|
rowStart: rowEnd + 1,
|
||||||
|
rowEnd: row,
|
||||||
|
columnStart,
|
||||||
|
columnEnd,
|
||||||
|
border: 'top',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Expanding by columns (left)
|
||||||
|
return {
|
||||||
|
rowStart,
|
||||||
|
rowEnd,
|
||||||
|
columnStart: column,
|
||||||
|
columnEnd: columnStart - 1,
|
||||||
|
border: 'right',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row <= rowEnd && column >= columnEnd) {
|
||||||
|
// We are expanding up and right
|
||||||
|
if (rowStart - row > column - columnEnd) {
|
||||||
|
// Expanding by rows (up)
|
||||||
|
return {
|
||||||
|
rowStart: row,
|
||||||
|
rowEnd: rowStart - 1,
|
||||||
|
columnStart,
|
||||||
|
columnEnd,
|
||||||
|
border: 'bottom',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Expanding by columns (right)
|
||||||
|
return {
|
||||||
|
rowStart,
|
||||||
|
rowEnd,
|
||||||
|
columnStart: columnEnd + 1,
|
||||||
|
columnEnd: column,
|
||||||
|
border: 'left',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row <= rowEnd && column <= columnStart) {
|
||||||
|
// We are expanding up and left
|
||||||
|
if (rowStart - row > columnStart - column) {
|
||||||
|
// Expanding by rows (up)
|
||||||
|
return {
|
||||||
|
rowStart: row,
|
||||||
|
rowEnd: rowStart - 1,
|
||||||
|
columnStart,
|
||||||
|
columnEnd,
|
||||||
|
border: 'bottom',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Expanding by columns (left)
|
||||||
|
return {
|
||||||
|
rowStart,
|
||||||
|
rowEnd,
|
||||||
|
columnStart: column,
|
||||||
|
columnEnd: columnStart - 1,
|
||||||
|
border: 'right',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the keypress should start editing
|
||||||
|
*/
|
||||||
|
export function isEditingKey(key: string): boolean {
|
||||||
|
if (key.length !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const code = key.codePointAt(0) ?? 0;
|
||||||
|
if (code > 0 && code < 255) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// / Common types
|
||||||
|
|
||||||
|
export interface Area {
|
||||||
|
rowStart: number;
|
||||||
|
rowEnd: number;
|
||||||
|
columnStart: number;
|
||||||
|
columnEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AreaWithBorderInterface extends Area {
|
||||||
|
border: 'left' | 'top' | 'right' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AreaWithBorder = AreaWithBorderInterface | null;
|
||||||
|
|
||||||
|
export interface Cell {
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrollPosition {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateSettings {
|
||||||
|
selectedCell: Cell;
|
||||||
|
selectedArea: Area;
|
||||||
|
scrollPosition: ScrollPosition;
|
||||||
|
extendToArea: AreaWithBorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Dispatch<A> = (value: A) => void;
|
||||||
|
export type SetStateAction<S> = S | ((prevState: S) => S);
|
||||||
|
|
||||||
|
export enum FocusType {
|
||||||
|
Cell = 'cell',
|
||||||
|
FormulaBar = 'formula-bar',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In Excel there are two "modes" of editing
|
||||||
|
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
|
||||||
|
* * `edit`: If you double click on a cell or click in the cell while editing.
|
||||||
|
* In this mode arrow keys will move within the cell.
|
||||||
|
*
|
||||||
|
* In a formula bar mode is always `edit`.
|
||||||
|
*/
|
||||||
|
export type CellEditMode = 'init' | 'edit';
|
||||||
|
export interface CellEditingType {
|
||||||
|
/**
|
||||||
|
* ID of cell editing. Useful when one edit transforms into another and some code needs to run
|
||||||
|
* when target changes.
|
||||||
|
*
|
||||||
|
* Due to problems with focus management (see #339) it's possible to start a new cell editing
|
||||||
|
* without properly cleaning up previous one (lose focus in workbook, regain focus NOT in
|
||||||
|
* the input and then use the keyboard.
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
sheet: number;
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
text: string;
|
||||||
|
base: string;
|
||||||
|
mode: CellEditMode;
|
||||||
|
focus: FocusType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavigationKey = 'ArrowRight' | 'ArrowLeft' | 'ArrowDown' | 'ArrowUp' | 'Home' | 'End';
|
||||||
|
|
||||||
|
export const isNavigationKey = (key: string): key is NavigationKey =>
|
||||||
|
['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(key);
|
||||||
|
|
||||||
|
function nameNeedsQuoting(name: string): boolean {
|
||||||
|
const chars = [' ', '(', ')', "'", '$', ',', ';', '-', '+', '{', '}'];
|
||||||
|
const l = chars.length;
|
||||||
|
for (let index = 0; index < l; index += 1) {
|
||||||
|
if (name.includes(chars[index])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: We should use the function of a similar name in the rust code.
|
||||||
|
export const quoteSheetName = (name: string): string => {
|
||||||
|
if (nameNeedsQuoting(name)) {
|
||||||
|
return `'${name.replace("'", "''")}'`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cellReprToRowColumn(cellRepr: string): { row: number; column: number } {
|
||||||
|
let row = 0;
|
||||||
|
let column = 0;
|
||||||
|
for (const character of cellRepr) {
|
||||||
|
if (Number.isNaN(Number.parseInt(character, 10))) {
|
||||||
|
column *= 26;
|
||||||
|
const characterCode = character.codePointAt(0);
|
||||||
|
const ACharacterCode = 'A'.codePointAt(0);
|
||||||
|
if (typeof characterCode === 'undefined' || typeof ACharacterCode === 'undefined') {
|
||||||
|
throw new TypeError('Failed to find character code');
|
||||||
|
}
|
||||||
|
const deltaCodes = characterCode - ACharacterCode;
|
||||||
|
if (deltaCodes < 0) {
|
||||||
|
throw new Error('Incorrect character');
|
||||||
|
}
|
||||||
|
column += deltaCodes + 1;
|
||||||
|
} else {
|
||||||
|
row *= 10;
|
||||||
|
row += Number.parseInt(character, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { row, column };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMessageCellText = (
|
||||||
|
cell: string,
|
||||||
|
getMessageSheetNumber: (sheet: string) => number | undefined,
|
||||||
|
getCellText?: (sheet: number, row: number, column: number) => string | undefined,
|
||||||
|
) => {
|
||||||
|
const messageMatch = /^=?(?<sheet>\w+)!(?<cell>\w+)/.exec(cell);
|
||||||
|
if (messageMatch && messageMatch.groups) {
|
||||||
|
const messageSheet = getMessageSheetNumber(messageMatch.groups.sheet);
|
||||||
|
const dynamicIconCell = cellReprToRowColumn(messageMatch.groups.cell);
|
||||||
|
if (messageSheet !== undefined && getCellText) {
|
||||||
|
return getCellText(messageSheet, dynamicIconCell.row, dynamicIconCell.column) || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCellAddress = (selectedArea: Area, selectedCell?: Cell) => {
|
||||||
|
const isSingleCell =
|
||||||
|
selectedArea.rowStart === selectedArea.rowEnd &&
|
||||||
|
selectedArea.columnEnd === selectedArea.columnStart;
|
||||||
|
|
||||||
|
return isSingleCell && selectedCell
|
||||||
|
? `${columnNameFromNumber(selectedCell.column)}${selectedCell.row}`
|
||||||
|
: `${columnNameFromNumber(selectedArea.columnStart)}${
|
||||||
|
selectedArea.rowStart
|
||||||
|
}:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum Border {
|
||||||
|
Top = 'top',
|
||||||
|
Bottom = 'bottom',
|
||||||
|
Right = 'right',
|
||||||
|
Left = 'left',
|
||||||
|
}
|
||||||
1328
webapp/src/components/WorksheetCanvas/worksheetCanvas.ts
Normal file
566
webapp/src/components/borderPicker.tsx
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
BorderBottomIcon,
|
||||||
|
BorderCenterHIcon,
|
||||||
|
BorderCenterVIcon,
|
||||||
|
BorderInnerIcon,
|
||||||
|
BorderLeftIcon,
|
||||||
|
BorderOuterIcon,
|
||||||
|
BorderRightIcon,
|
||||||
|
BorderTopIcon,
|
||||||
|
BorderNoneIcon,
|
||||||
|
BorderStyleIcon,
|
||||||
|
} from "../icons";
|
||||||
|
import ColorPicker from "./colorPicker";
|
||||||
|
import Popover, { PopoverOrigin } from "@mui/material/Popover";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Grid2X2 as BorderAllIcon,
|
||||||
|
PencilLine,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
import { BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm";
|
||||||
|
|
||||||
|
type BorderPickerProps = {
|
||||||
|
className?: string;
|
||||||
|
onChange: (border: BorderOptions) => void;
|
||||||
|
anchorEl: React.RefObject<HTMLElement>;
|
||||||
|
anchorOrigin?: PopoverOrigin;
|
||||||
|
transformOrigin?: PopoverOrigin;
|
||||||
|
open: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BorderPicker = (properties: BorderPickerProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [borderSelected, setBorderSelected] = useState(BorderType.None);
|
||||||
|
const [borderColor, setBorderColor] = useState("#000000");
|
||||||
|
const [borderStyle, setBorderStyle] = useState(BorderStyle.Thin);
|
||||||
|
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||||
|
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
||||||
|
const closePicker = (): void => {
|
||||||
|
properties.onChange({
|
||||||
|
color: borderColor,
|
||||||
|
style: borderStyle,
|
||||||
|
border: borderSelected,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const borderColorButton = useRef(null);
|
||||||
|
const borderStyleButton = useRef(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledPopover
|
||||||
|
open={properties.open}
|
||||||
|
onClose={(): void => closePicker()}
|
||||||
|
anchorEl={properties.anchorEl.current}
|
||||||
|
anchorOrigin={
|
||||||
|
properties.anchorOrigin || { vertical: "bottom", horizontal: "left" }
|
||||||
|
}
|
||||||
|
transformOrigin={
|
||||||
|
properties.transformOrigin || { vertical: "top", horizontal: "left" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BorderPickerDialog>
|
||||||
|
<Borders>
|
||||||
|
<Line>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderAll}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderAll) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderAll);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderAllIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderInner}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderInner) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderInner);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderInnerIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderCenterH}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderCenterH) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderCenterH);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderCenterHIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderCenterV}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderCenterV) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderCenterV);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderCenterVIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderOuter}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderOuter) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderOuter);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderOuterIcon />
|
||||||
|
</Button>
|
||||||
|
</Line>
|
||||||
|
<Line>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderNone}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderNone) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderNone);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderNoneIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderTop}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderTop) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderTop);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderTopIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderRight}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderRight) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderRight);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderRightIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderBottom}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderBottom) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderBottom);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderBottomIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={borderSelected === BorderType.BorderLeft}
|
||||||
|
onClick={() => {
|
||||||
|
if (borderSelected === BorderType.BorderLeft) {
|
||||||
|
setBorderSelected(BorderType.None);
|
||||||
|
} else {
|
||||||
|
setBorderSelected(BorderType.BorderLeft);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderLeftIcon />
|
||||||
|
</Button>
|
||||||
|
</Line>
|
||||||
|
</Borders>
|
||||||
|
<Divider />
|
||||||
|
<Styles>
|
||||||
|
<ButtonWrapper onClick={() => setColorPickerOpen(true)}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
disabled={false}
|
||||||
|
ref={borderColorButton}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<PencilLine />
|
||||||
|
</Button>
|
||||||
|
<div style={{flexGrow:2}}>Border color</div>
|
||||||
|
<ChevronRightStyled />
|
||||||
|
</ButtonWrapper>
|
||||||
|
<ButtonWrapper onClick={() => setStylePickerOpen(true)} ref={borderStyleButton}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
disabled={false}
|
||||||
|
title={t("workbook.toolbar.borders_button_title")}
|
||||||
|
>
|
||||||
|
<BorderStyleIcon />
|
||||||
|
</Button>
|
||||||
|
<div style={{flexGrow:2}}>Border style</div>
|
||||||
|
<ChevronRightStyled />
|
||||||
|
</ButtonWrapper>
|
||||||
|
</Styles>
|
||||||
|
</BorderPickerDialog>
|
||||||
|
<ColorPicker
|
||||||
|
color={borderColor}
|
||||||
|
onChange={(color): void => {
|
||||||
|
setBorderColor(color);
|
||||||
|
setColorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
anchorEl={borderColorButton}
|
||||||
|
open={colorPickerOpen}
|
||||||
|
/>
|
||||||
|
<StyledPopover
|
||||||
|
open={stylePickerOpen}
|
||||||
|
onClose={(): void => {
|
||||||
|
setStylePickerOpen(false);
|
||||||
|
}}
|
||||||
|
anchorEl={borderStyleButton.current}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
transformOrigin={{ vertical: 38, horizontal: -6 }}
|
||||||
|
>
|
||||||
|
<BorderStyleDialog>
|
||||||
|
<LineWrapper
|
||||||
|
onClick={() => {
|
||||||
|
setBorderStyle(BorderStyle.Dashed);
|
||||||
|
setStylePickerOpen(false);
|
||||||
|
}}
|
||||||
|
$checked={borderStyle === BorderStyle.None}
|
||||||
|
>
|
||||||
|
<BorderDescription>None</BorderDescription>
|
||||||
|
<NoneLine />
|
||||||
|
</LineWrapper>
|
||||||
|
<LineWrapper
|
||||||
|
onClick={() => {
|
||||||
|
setBorderStyle(BorderStyle.Thin);
|
||||||
|
setStylePickerOpen(false);
|
||||||
|
}}
|
||||||
|
$checked={borderStyle === BorderStyle.Thin}
|
||||||
|
>
|
||||||
|
<BorderDescription>Thin</BorderDescription>
|
||||||
|
<SolidLine />
|
||||||
|
</LineWrapper>
|
||||||
|
<LineWrapper
|
||||||
|
onClick={() => {
|
||||||
|
setBorderStyle(BorderStyle.Medium);
|
||||||
|
setStylePickerOpen(false);
|
||||||
|
}}
|
||||||
|
$checked={borderStyle === BorderStyle.Medium}
|
||||||
|
>
|
||||||
|
<BorderDescription>Medium</BorderDescription>
|
||||||
|
<MediumLine />
|
||||||
|
</LineWrapper>
|
||||||
|
<LineWrapper
|
||||||
|
onClick={() => {
|
||||||
|
setBorderStyle(BorderStyle.Thick);
|
||||||
|
setStylePickerOpen(false);
|
||||||
|
}}
|
||||||
|
$checked={borderStyle === BorderStyle.Thick}
|
||||||
|
>
|
||||||
|
<BorderDescription>Thick</BorderDescription>
|
||||||
|
<ThickLine />
|
||||||
|
</LineWrapper>
|
||||||
|
<LineWrapper
|
||||||
|
onClick={() => {
|
||||||
|
setBorderStyle(BorderStyle.Dotted);
|
||||||
|
setStylePickerOpen(false);
|
||||||
|
}}
|
||||||
|
$checked={borderStyle === BorderStyle.Dotted}
|
||||||
|
>
|
||||||
|
<BorderDescription>Dotted</BorderDescription>
|
||||||
|
<DottedLine />
|
||||||
|
</LineWrapper>
|
||||||
|
<LineWrapper
|
||||||
|
onClick={() => {
|
||||||
|
setBorderStyle(BorderStyle.Dashed);
|
||||||
|
setStylePickerOpen(false);
|
||||||
|
}}
|
||||||
|
$checked={borderStyle === BorderStyle.Dashed}
|
||||||
|
>
|
||||||
|
<BorderDescription>Dashed</BorderDescription>
|
||||||
|
<DashedLine />
|
||||||
|
</LineWrapper>
|
||||||
|
<LineWrapper
|
||||||
|
onClick={() => {
|
||||||
|
setBorderStyle(BorderStyle.Dashed);
|
||||||
|
setStylePickerOpen(false);
|
||||||
|
}}
|
||||||
|
$checked={borderStyle === BorderStyle.Double}
|
||||||
|
>
|
||||||
|
<BorderDescription>Double</BorderDescription>
|
||||||
|
<DoubleLine />
|
||||||
|
</LineWrapper>
|
||||||
|
</BorderStyleDialog>
|
||||||
|
</StyledPopover>
|
||||||
|
</StyledPopover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type LineWrapperProperties = { $checked: boolean };
|
||||||
|
const LineWrapper = styled("div")<LineWrapperProperties>`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background-color: ${({ $checked }): string => {
|
||||||
|
if ($checked) {
|
||||||
|
return '#EEEEEE;';
|
||||||
|
} else {
|
||||||
|
return 'inherit;';
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
&:hover {
|
||||||
|
border: 1px solid #EEEEEE;
|
||||||
|
}
|
||||||
|
padding:8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
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")`
|
||||||
|
width: 68px;
|
||||||
|
border-top: 1px solid #E0E0E0;
|
||||||
|
`;
|
||||||
|
const SolidLine = styled("div")`
|
||||||
|
width: 68px;
|
||||||
|
border-top: 1px solid #333333;
|
||||||
|
`;
|
||||||
|
const MediumLine = styled("div")`
|
||||||
|
width: 68px;
|
||||||
|
border-top: 2px solid #333333;
|
||||||
|
`;
|
||||||
|
const ThickLine = styled("div")`
|
||||||
|
width: 68px;
|
||||||
|
border-top: 3px solid #333333;
|
||||||
|
`;
|
||||||
|
const DashedLine = styled("div")`
|
||||||
|
width: 68px;
|
||||||
|
border-top: 1px dashed #333333;
|
||||||
|
`;
|
||||||
|
const DottedLine = styled("div")`
|
||||||
|
width: 68px;
|
||||||
|
border-top: 1px dotted #333333;
|
||||||
|
`;
|
||||||
|
const DoubleLine = styled('div')`
|
||||||
|
width: 68px;
|
||||||
|
border-top: 3px double #333333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Divider = styled("div")`
|
||||||
|
display: inline-flex;
|
||||||
|
heigh: 1px;
|
||||||
|
border-bottom: 1px solid #EEE;
|
||||||
|
margin-left: 0px;
|
||||||
|
margin-right: 0px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Borders = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Styles = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Line = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ButtonWrapper = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
&:hover {
|
||||||
|
background-color: #EEE;
|
||||||
|
border-top-color: ${(): string => theme.palette.grey["400"]};
|
||||||
|
}
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BorderStyleDialog = styled("div")`
|
||||||
|
background: ${({ theme }): string => theme.palette.background.default};
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledPopover = styled(Popover)`
|
||||||
|
.MuiPopover-paper {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0px solid ${({ theme }): string => theme.palette.background.default};
|
||||||
|
box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5);
|
||||||
|
}
|
||||||
|
.MuiPopover-padding {
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
.MuiList-padding {
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
font-family: ${({ theme }) => theme.typography.fontFamily};
|
||||||
|
font-size: 13px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BorderPickerDialog = styled("div")`
|
||||||
|
background: ${({ theme }): string => theme.palette.background.default};
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BorderDescription = styled("div")`
|
||||||
|
width: 70px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
|
||||||
|
// const Button = styled.button<TypeButtonProperties>`
|
||||||
|
// width: 23px;
|
||||||
|
// height: 23px;
|
||||||
|
// display: inline-flex;
|
||||||
|
// align-items: center;
|
||||||
|
// justify-content: center;
|
||||||
|
// font-size: 14px;
|
||||||
|
// border-radius: 2px;
|
||||||
|
// margin-right: 5px;
|
||||||
|
// transition: all 0.2s;
|
||||||
|
|
||||||
|
// ${({ theme, disabled, $pressed, $underlinedColor }): string => {
|
||||||
|
// if (disabled) {
|
||||||
|
// return `
|
||||||
|
// color: ${theme.palette.grey['600']};
|
||||||
|
// cursor: default;
|
||||||
|
// `;
|
||||||
|
// }
|
||||||
|
// return `
|
||||||
|
// border-top: ${$underlinedColor ? '3px solid #FFF' : 'none'};
|
||||||
|
// border-bottom: ${$underlinedColor ? `3px solid ${$underlinedColor}` : 'none'};
|
||||||
|
// color: ${theme.palette.text.primary};
|
||||||
|
// background-color: ${$pressed ? theme.palette.grey['600'] : '#FFF'};
|
||||||
|
// &:hover {
|
||||||
|
// background-color: ${theme.palette.grey['400']};
|
||||||
|
// border-top-color: ${theme.palette.grey['400']};
|
||||||
|
// }
|
||||||
|
// `;
|
||||||
|
// }}
|
||||||
|
// `;
|
||||||
|
|
||||||
|
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
|
||||||
|
const Button = styled("button")<TypeButtonProperties>(
|
||||||
|
({ disabled, $pressed, $underlinedColor }) => {
|
||||||
|
let result: Record<string, any> = {
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
// fontSize: "26px",
|
||||||
|
border: "0px solid #fff",
|
||||||
|
borderRadius: "2px",
|
||||||
|
marginRight: "5px",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "0px",
|
||||||
|
};
|
||||||
|
if (disabled) {
|
||||||
|
result.color = theme.palette.grey["600"];
|
||||||
|
result.cursor = "default";
|
||||||
|
} else {
|
||||||
|
result.borderTop = $underlinedColor ? "3px solid #FFF" : "none";
|
||||||
|
result.borderBottom = $underlinedColor
|
||||||
|
? `3px solid ${$underlinedColor}`
|
||||||
|
: "none";
|
||||||
|
(result.color = "#21243A"),
|
||||||
|
(result.backgroundColor = $pressed
|
||||||
|
? theme.palette.grey["600"]
|
||||||
|
: "inherit");
|
||||||
|
result["&:hover"] = {
|
||||||
|
backgroundColor: "#F1F2F8",
|
||||||
|
borderTopColor: "#F1F2F8",
|
||||||
|
};
|
||||||
|
result["svg"] = {
|
||||||
|
width: "16px",
|
||||||
|
height: "16px",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronRightStyled = styled(ChevronRight)`
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default BorderPicker;
|
||||||
261
webapp/src/components/colorPicker.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import styled from "@emotion/styled";
|
||||||
|
import Popover, { PopoverOrigin } from "@mui/material/Popover";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { HexColorInput, HexColorPicker } from "react-colorful";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
|
||||||
|
type ColorPickerProps = {
|
||||||
|
className?: string;
|
||||||
|
color: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
anchorEl: React.RefObject<HTMLElement>;
|
||||||
|
anchorOrigin?: PopoverOrigin;
|
||||||
|
transformOrigin?: PopoverOrigin;
|
||||||
|
open: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorPickerWidth = 240;
|
||||||
|
const colorPickerPadding = 15;
|
||||||
|
const colorfulHeight = 185; // 150 + 15 + 20
|
||||||
|
|
||||||
|
const ColorPicker = (properties: ColorPickerProps) => {
|
||||||
|
const [color, setColor] = useState<string>(properties.color);
|
||||||
|
const recentColors = useRef<string[]>([]);
|
||||||
|
|
||||||
|
const closePicker = (newColor: string): void => {
|
||||||
|
const maxRecentColors = 14;
|
||||||
|
properties.onChange(newColor);
|
||||||
|
const colors = recentColors.current.filter((c) => c !== newColor);
|
||||||
|
recentColors.current = [newColor, ...colors].slice(0, maxRecentColors);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColor(properties.color);
|
||||||
|
}, [properties.color]);
|
||||||
|
|
||||||
|
const presetColors = [
|
||||||
|
"#FFFFFF",
|
||||||
|
"#1B717E",
|
||||||
|
"#59B9BC",
|
||||||
|
"#3BB68A",
|
||||||
|
"#8CB354",
|
||||||
|
"#F8CD3C",
|
||||||
|
"#EC5753",
|
||||||
|
"#A23C52",
|
||||||
|
"#D03627",
|
||||||
|
"#523E93",
|
||||||
|
"#3358B7",
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={properties.open}
|
||||||
|
onClose={(): void => closePicker(color)}
|
||||||
|
anchorEl={properties.anchorEl.current}
|
||||||
|
anchorOrigin={
|
||||||
|
properties.anchorOrigin || { vertical: "bottom", horizontal: "left" }
|
||||||
|
}
|
||||||
|
transformOrigin={
|
||||||
|
properties.transformOrigin || { vertical: "top", horizontal: "left" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ColorPickerDialog>
|
||||||
|
<HexColorPicker
|
||||||
|
color={color}
|
||||||
|
onChange={(newColor): void => {
|
||||||
|
setColor(newColor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ColorPickerInput>
|
||||||
|
<HexWrapper>
|
||||||
|
<HexLabel>{"Hex"}</HexLabel>
|
||||||
|
<HexColorInputBox>
|
||||||
|
<HashLabel>{"#"}</HashLabel>
|
||||||
|
<HexColorInput
|
||||||
|
color={color}
|
||||||
|
onChange={(newColor): void => {
|
||||||
|
setColor(newColor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HexColorInputBox>
|
||||||
|
</HexWrapper>
|
||||||
|
<Swatch $color={color} />
|
||||||
|
</ColorPickerInput>
|
||||||
|
<HorizontalDivider />
|
||||||
|
<ColorList>
|
||||||
|
{presetColors.map((presetColor) => (
|
||||||
|
<Button
|
||||||
|
key={presetColor}
|
||||||
|
$color={presetColor}
|
||||||
|
onClick={(): void => {
|
||||||
|
closePicker(presetColor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ColorList>
|
||||||
|
<HorizontalDivider />
|
||||||
|
<RecentLabel>{"Recent"}</RecentLabel>
|
||||||
|
<ColorList>
|
||||||
|
{recentColors.current.map((recentColor) => (
|
||||||
|
<Button
|
||||||
|
key={recentColor}
|
||||||
|
$color={recentColor}
|
||||||
|
onClick={(): void => {
|
||||||
|
closePicker(recentColor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ColorList>
|
||||||
|
</ColorPickerDialog>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecentLabel = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${theme.palette.text.secondary};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ColorList = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-direction: row;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Button = styled.button<{ $color: string }>`
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
${({ $color }): string => {
|
||||||
|
if ($color.toUpperCase() === "#FFFFFF") {
|
||||||
|
return `border: 1px solid ${theme.palette.grey['600']};`;
|
||||||
|
}
|
||||||
|
return `border: 1px solid ${$color};`;
|
||||||
|
}}
|
||||||
|
background-color: ${({ $color }): string => {
|
||||||
|
return $color;
|
||||||
|
}};
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HorizontalDivider = styled.div`
|
||||||
|
height: 0px;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid ${theme.palette.grey['400']};
|
||||||
|
margin-top: 15px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// const StyledPopover = styled(Popover)`
|
||||||
|
// .MuiPopover-paper {
|
||||||
|
// border-radius: 10px;
|
||||||
|
// border: 0px solid ${theme.palette.background.default};
|
||||||
|
// box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5);
|
||||||
|
// }
|
||||||
|
// .MuiPopover-padding {
|
||||||
|
// padding: 0px;
|
||||||
|
// }
|
||||||
|
// .MuiList-padding {
|
||||||
|
// padding: 0px;
|
||||||
|
// }
|
||||||
|
// `;
|
||||||
|
|
||||||
|
const ColorPickerDialog = styled.div`
|
||||||
|
background: ${theme.palette.background.default};
|
||||||
|
width: ${colorPickerWidth}px;
|
||||||
|
padding: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
& .react-colorful {
|
||||||
|
height: ${colorfulHeight}px;
|
||||||
|
width: ${colorPickerWidth - colorPickerPadding * 2}px;
|
||||||
|
}
|
||||||
|
& .react-colorful__saturation {
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
& .react-colorful__hue {
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
& .react-colorful__saturation-pointer {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
& .react-colorful__hue-pointer {
|
||||||
|
width: 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HashLabel = styled.div`
|
||||||
|
margin: auto 0px auto 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #7d8ec2;
|
||||||
|
font-family: ${theme.typography.button.fontFamily};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HexLabel = styled.div`
|
||||||
|
margin: auto 10px auto 0px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
font-family: ${theme.typography.button.fontFamily};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HexColorInputBox = styled.div`
|
||||||
|
display: inline-flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 10px;
|
||||||
|
width: 140px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid ${theme.palette.grey['600']};
|
||||||
|
border-radius: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HexWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
& input {
|
||||||
|
min-width: 0px;
|
||||||
|
border: 0px;
|
||||||
|
background: ${theme.palette.background.default};
|
||||||
|
outline: none;
|
||||||
|
font-family: ${theme.typography.button.fontFamily};
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& input:focus {
|
||||||
|
border-color: #4298ef;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Swatch = styled.div<{ $color: string }>`
|
||||||
|
display: inline-flex;
|
||||||
|
${({ $color }): string => {
|
||||||
|
if ($color.toUpperCase() === "#FFFFFF") {
|
||||||
|
return `border: 1px solid ${theme.palette.grey['600']};`;
|
||||||
|
}
|
||||||
|
return `border: 1px solid ${$color};`;
|
||||||
|
}}
|
||||||
|
background-color: ${({ $color }): string => $color};
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ColorPickerInput = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default ColorPicker;
|
||||||
420
webapp/src/components/editor/editor.tsx
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
KeyboardEvent,
|
||||||
|
useContext,
|
||||||
|
} from "react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import EditorContext, { Area } from "./editorContext";
|
||||||
|
import { getStringRange } from "./util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the Cell Editor for IronCalc
|
||||||
|
* I uses a transparent textarea and a styled mask. What you see is the HTML styled content of the mask
|
||||||
|
* and the caret from the textarea. The alternative would be to have a 'contenteditable' div.
|
||||||
|
* That turns out to be a much more difficult implementation.
|
||||||
|
*
|
||||||
|
* The editor grows horizontally with text if it fits in the screen.
|
||||||
|
* If it doesn't fit, it wraps and grows vertically. If it doesn't fit vertically it scrolls.
|
||||||
|
*
|
||||||
|
* Many keyboard and mouse events are handled gracefully by the textarea in full or in part.
|
||||||
|
* For example letter key strokes like 'q' or '1' are handled full by the textarea.
|
||||||
|
* Some keyboard events like "RightArrow" might need to be handled separately and let them bubble up,
|
||||||
|
* or might be handled by the textarea, depending on the "editor mode".
|
||||||
|
* Some other like "Enter" we need to intercept and change the normal behaviour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const commonCSS: CSSProperties = {
|
||||||
|
fontWeight: "inherit",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: "inherit",
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
whiteSpace: "pre",
|
||||||
|
width: "100%",
|
||||||
|
padding: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
sheet: number;
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorOptions {
|
||||||
|
minimalWidth: number;
|
||||||
|
minimalHeight: number;
|
||||||
|
textColor: string;
|
||||||
|
originalText: string;
|
||||||
|
getStyledText: (
|
||||||
|
text: string,
|
||||||
|
insertRangeText: string
|
||||||
|
) => {
|
||||||
|
html: JSX.Element[];
|
||||||
|
isInReferenceMode: boolean;
|
||||||
|
};
|
||||||
|
onEditEnd: (text: string) => void;
|
||||||
|
display: boolean;
|
||||||
|
cell: Cell;
|
||||||
|
sheetNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
const Editor = (options: EditorOptions) => {
|
||||||
|
const {
|
||||||
|
minimalWidth,
|
||||||
|
minimalHeight,
|
||||||
|
textColor,
|
||||||
|
onEditEnd,
|
||||||
|
originalText,
|
||||||
|
display,
|
||||||
|
cell,
|
||||||
|
sheetNames,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [width, setWidth] = useState(minimalWidth);
|
||||||
|
const [height, setHeight] = useState(minimalHeight);
|
||||||
|
|
||||||
|
const { editorContext, setEditorContext } = useContext(EditorContext);
|
||||||
|
|
||||||
|
const setBaseText = (newText: string) => {
|
||||||
|
console.log('Calling setBaseText');
|
||||||
|
setEditorContext((c) => {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
baseText: newText,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertRangeText = editorContext.insertRange
|
||||||
|
? getStringRange(editorContext.insertRange, sheetNames)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const baseText = editorContext.baseText;
|
||||||
|
const text = baseText + insertRangeText;
|
||||||
|
console.log('baseText', baseText, 'insertRange:', insertRangeText);
|
||||||
|
|
||||||
|
const formulaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const maskRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// setBaseText(originalText);
|
||||||
|
// }, [cell]);
|
||||||
|
|
||||||
|
const { html: styledFormula, isInReferenceMode } = options.getStyledText(
|
||||||
|
baseText,
|
||||||
|
insertRangeText
|
||||||
|
);
|
||||||
|
|
||||||
|
if (display && textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formulaRef.current) {
|
||||||
|
const scrollWidth = formulaRef.current.scrollWidth;
|
||||||
|
if (scrollWidth > width) {
|
||||||
|
setWidth(scrollWidth);
|
||||||
|
} else if (scrollWidth <= minimalWidth) {
|
||||||
|
setWidth(minimalWidth);
|
||||||
|
}
|
||||||
|
const scrollHeight = formulaRef.current.scrollHeight;
|
||||||
|
if (scrollHeight > height) {
|
||||||
|
setHeight(scrollHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInReferenceMode) {
|
||||||
|
setEditorContext((c) => {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
mode: "insert",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditorContext((c) => {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
mode: "cruise",
|
||||||
|
insertRange: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isInReferenceMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (display && textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [display]);
|
||||||
|
|
||||||
|
console.log("Ok, this is running", text, editorContext.id);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
const { key, shiftKey, altKey } = event;
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const mode = editorContext.mode;
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (key) {
|
||||||
|
case "Enter": {
|
||||||
|
if (altKey) {
|
||||||
|
// new line
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const newText = text.slice(0, start) + "\n" + text.slice(end);
|
||||||
|
setBaseText(newText);
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.setSelectionRange(start + 1, start + 1);
|
||||||
|
}, 1);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// end edit
|
||||||
|
onEditEnd(text);
|
||||||
|
textarea.blur();
|
||||||
|
// event bubbles up
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "Escape": {
|
||||||
|
setBaseText(originalText);
|
||||||
|
textarea.blur();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowLeft": {
|
||||||
|
if (mode === "accept") {
|
||||||
|
onEditEnd(text);
|
||||||
|
textarea.blur();
|
||||||
|
// event bubbles up
|
||||||
|
return;
|
||||||
|
} else if (mode == "insert") {
|
||||||
|
if (shiftKey) {
|
||||||
|
// increase the inserted range to the left
|
||||||
|
if (!editorContext.insertRange) {
|
||||||
|
setEditorContext((c) => {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
insertRange: {
|
||||||
|
absoluteColumnEnd: false,
|
||||||
|
absoluteColumnStart: false,
|
||||||
|
absoluteRowEnd: false,
|
||||||
|
absoluteRowStart: false,
|
||||||
|
sheet: cell.sheet,
|
||||||
|
rowStart: cell.row,
|
||||||
|
rowEnd: cell.row,
|
||||||
|
columnStart: cell.column,
|
||||||
|
columnEnd: cell.column,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// const r = insertRage;
|
||||||
|
// r.columnStart = Math.max(r.columnStart - 1, 1);
|
||||||
|
// setInsertRange(r);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// move inserted cell to the left
|
||||||
|
if (!editorContext.insertRange) {
|
||||||
|
setEditorContext((c) => {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
insertRange: {
|
||||||
|
absoluteColumnEnd: false,
|
||||||
|
absoluteColumnStart: false,
|
||||||
|
absoluteRowEnd: false,
|
||||||
|
absoluteRowStart: false,
|
||||||
|
sheet: cell.sheet,
|
||||||
|
rowStart: cell.row,
|
||||||
|
rowEnd: cell.row,
|
||||||
|
columnStart: cell.column,
|
||||||
|
columnEnd: cell.column,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditorContext((c) => {
|
||||||
|
const range = c.insertRange as Area;
|
||||||
|
const row = range.rowStart;
|
||||||
|
let column = range.columnStart - 1;
|
||||||
|
if (column < 1) {
|
||||||
|
column = 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
insertRange: {
|
||||||
|
absoluteColumnEnd: false,
|
||||||
|
absoluteColumnStart: false,
|
||||||
|
absoluteRowEnd: false,
|
||||||
|
absoluteRowStart: false,
|
||||||
|
sheet: range.sheet,
|
||||||
|
rowStart: row,
|
||||||
|
rowEnd: row,
|
||||||
|
columnStart: column,
|
||||||
|
columnEnd: column,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// We don't do anything in "cruise mode" and rely on the textarea default behaviour
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowDown": {
|
||||||
|
if (mode === "accept") {
|
||||||
|
onEditEnd(text);
|
||||||
|
textarea.blur();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowRight": {
|
||||||
|
if (mode === "accept") {
|
||||||
|
onEditEnd(text);
|
||||||
|
textarea.blur();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowUp": {
|
||||||
|
if (mode === "accept") {
|
||||||
|
onEditEnd(text);
|
||||||
|
textarea.blur();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "Tab": {
|
||||||
|
onEditEnd(text);
|
||||||
|
textarea.blur();
|
||||||
|
// event bubbles up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (editorContext.mode === "insert") {
|
||||||
|
setBaseText(text);
|
||||||
|
setEditorContext((context) => {
|
||||||
|
return {
|
||||||
|
...context,
|
||||||
|
mode: "cruise",
|
||||||
|
insertRange: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[text, editorContext]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "#FFF",
|
||||||
|
display: display ? "block" : "none",
|
||||||
|
}}
|
||||||
|
onClick={(_event) => {
|
||||||
|
console.log("Click on wrapper");
|
||||||
|
}}
|
||||||
|
onPointerDown={() => {
|
||||||
|
console.log("On pointer down wrapper");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={maskRef}
|
||||||
|
style={{
|
||||||
|
...commonCSS,
|
||||||
|
textAlign: "left",
|
||||||
|
pointerEvents: "none",
|
||||||
|
height,
|
||||||
|
}}
|
||||||
|
onClick={(_event) => {
|
||||||
|
console.log("Click on mask");
|
||||||
|
}}
|
||||||
|
onPointerDown={() => {
|
||||||
|
console.log("On pointer down mask");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={formulaRef}>{styledFormula}</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
rows={1}
|
||||||
|
style={{
|
||||||
|
...commonCSS,
|
||||||
|
color: "transparent",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
caretColor: textColor,
|
||||||
|
outline: "none",
|
||||||
|
resize: "none",
|
||||||
|
border: "none",
|
||||||
|
height,
|
||||||
|
}}
|
||||||
|
spellCheck="false"
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => {
|
||||||
|
console.log("onChange", event.target.value);
|
||||||
|
setBaseText(event.target.value);
|
||||||
|
}}
|
||||||
|
onScroll={() => {
|
||||||
|
if (maskRef.current && textareaRef.current) {
|
||||||
|
maskRef.current.style.left = `-${textareaRef.current.scrollLeft}px`;
|
||||||
|
maskRef.current.style.top = `-${textareaRef.current.scrollTop}px`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onClick={(event) => {
|
||||||
|
console.log("Setting mode");
|
||||||
|
setEditorContext((c) => {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
mode: "cruise",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log("here");
|
||||||
|
// if (display) {
|
||||||
|
event.stopPropagation();
|
||||||
|
// }
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// on blur
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Editor;
|
||||||
45
webapp/src/components/editor/editorContext.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Dispatch, SetStateAction, createContext } from "react";
|
||||||
|
|
||||||
|
export interface Area {
|
||||||
|
sheet: number | null;
|
||||||
|
rowStart: number;
|
||||||
|
rowEnd: number;
|
||||||
|
columnStart: number;
|
||||||
|
columnEnd: number;
|
||||||
|
absoluteRowStart: boolean;
|
||||||
|
absoluteRowEnd: boolean;
|
||||||
|
absoluteColumnStart: boolean;
|
||||||
|
absoluteColumnEnd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow keys behave in different ways depending on the "edit mode":
|
||||||
|
// * In _cruise_ mode arrowy keys navigate within the editor
|
||||||
|
// * In _accept_ mode pressing an arrow key will end editing
|
||||||
|
// * In _insert_ mode arrow keys will change the selected range
|
||||||
|
export type EditorMode = "cruise" | "accept" | "insert";
|
||||||
|
|
||||||
|
export interface EditorState {
|
||||||
|
mode: EditorMode;
|
||||||
|
insertRange: null | Area;
|
||||||
|
baseText: string;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorContextType {
|
||||||
|
editorContext: EditorState;
|
||||||
|
setEditorContext: Dispatch<
|
||||||
|
SetStateAction<{ mode: EditorMode; insertRange: null | Area }>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorContext = createContext<EditorContextType>({
|
||||||
|
editorContext: {
|
||||||
|
mode: "accept",
|
||||||
|
insertRange: null,
|
||||||
|
baseText: '',
|
||||||
|
id: Math.floor(Math.random()*1000),
|
||||||
|
},
|
||||||
|
setEditorContext: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EditorContext;
|
||||||
3
webapp/src/components/editor/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from './editor';
|
||||||
|
|
||||||
|
|
||||||
92
webapp/src/components/editor/tokenTypes.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
type ErrorType =
|
||||||
|
| 'REF'
|
||||||
|
| 'NAME'
|
||||||
|
| 'VALUE'
|
||||||
|
| 'DIV'
|
||||||
|
| 'NA'
|
||||||
|
| 'NUM'
|
||||||
|
| 'ERROR'
|
||||||
|
| 'NIMPL'
|
||||||
|
| 'SPILL'
|
||||||
|
| 'CALC'
|
||||||
|
| 'CIRC';
|
||||||
|
|
||||||
|
type OpCompareType =
|
||||||
|
| 'LessThan'
|
||||||
|
| 'GreaterThan'
|
||||||
|
| 'Equal'
|
||||||
|
| 'LessOrEqualThan'
|
||||||
|
| 'GreaterOrEqualThan'
|
||||||
|
| 'NonEqual';
|
||||||
|
|
||||||
|
type OpSumType = 'Add' | 'Minus';
|
||||||
|
|
||||||
|
type OpProductType = 'Times' | 'Divide';
|
||||||
|
|
||||||
|
interface ReferenceType {
|
||||||
|
sheet: string | null;
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
absolute_column: boolean;
|
||||||
|
absolute_row: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedReferenceType {
|
||||||
|
column: number;
|
||||||
|
row: number;
|
||||||
|
absolute_column: boolean;
|
||||||
|
absolute_row: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Reference {
|
||||||
|
Reference: ReferenceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Range {
|
||||||
|
Range: {
|
||||||
|
sheet: string | null;
|
||||||
|
left: ParsedReferenceType;
|
||||||
|
right: ParsedReferenceType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenType =
|
||||||
|
| 'Illegal'
|
||||||
|
| 'Eof'
|
||||||
|
| { Ident: string }
|
||||||
|
| { String: string }
|
||||||
|
| { Boolean: boolean }
|
||||||
|
| { Number: number }
|
||||||
|
| { ERROR: ErrorType }
|
||||||
|
| { COMPARE: OpCompareType }
|
||||||
|
| { SUM: OpSumType }
|
||||||
|
| { PRODUCT: OpProductType }
|
||||||
|
| 'POWER'
|
||||||
|
| 'LPAREN'
|
||||||
|
| 'RPAREN'
|
||||||
|
| 'COLON'
|
||||||
|
| 'SEMICOLON'
|
||||||
|
| 'LBRACKET'
|
||||||
|
| 'RBRACKET'
|
||||||
|
| 'LBRACE'
|
||||||
|
| 'RBRACE'
|
||||||
|
| 'COMMA'
|
||||||
|
| 'BANG'
|
||||||
|
| 'PERCENT'
|
||||||
|
| 'AND'
|
||||||
|
| Reference
|
||||||
|
| Range;
|
||||||
|
|
||||||
|
export interface MarkedToken {
|
||||||
|
token: TokenType;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
108
webapp/src/components/editor/useEditorKeyDown.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, KeyboardEvent } from "react";
|
||||||
|
import { WorkbookState } from "../workbookState";
|
||||||
|
import { Model } from "@ironcalc/wasm";
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
// onMoveCaretToStart: () => void;
|
||||||
|
// onMoveCaretToEnd: () => void;
|
||||||
|
// onEditEnd: (delta: { deltaRow: number; deltaColumn: number }) => void;
|
||||||
|
// onEditEscape: () => void;
|
||||||
|
// onReferenceCycle: () => void;
|
||||||
|
// text: string;
|
||||||
|
// setText: (text: string) => void;
|
||||||
|
model: Model;
|
||||||
|
state: WorkbookState;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useEditorKeydown = (
|
||||||
|
options: Options
|
||||||
|
): {
|
||||||
|
onKeyDown: (event: KeyboardEvent) => void;
|
||||||
|
} => {
|
||||||
|
const { state, model } = options;
|
||||||
|
const onKeyDown = useCallback((event: KeyboardEvent) => {
|
||||||
|
const { key, shiftKey } = event;
|
||||||
|
const { mode, text } = state.getEditor() ?? { mode: "init", text: "" };
|
||||||
|
switch (key) {
|
||||||
|
case "Enter":
|
||||||
|
// options.onEditEnd({ deltaRow: 1, deltaColumn: 0 });
|
||||||
|
const { row, column } = state.getSelectedCell();
|
||||||
|
const sheet = state.getSelectedSheet();
|
||||||
|
model.setUserInput(sheet, row, column, text);
|
||||||
|
state.selectCell({ row: row + 1, column });
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
options.refresh();
|
||||||
|
break;
|
||||||
|
// case 'ArrowUp': {
|
||||||
|
// if (mode === 'init') {
|
||||||
|
// options.onEditEnd({ deltaRow: -1, deltaColumn: 0 });
|
||||||
|
// } else {
|
||||||
|
// options.onMoveCaretToStart();
|
||||||
|
// }
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// case 'ArrowDown': {
|
||||||
|
// if (mode === 'init') {
|
||||||
|
// options.onEditEnd({ deltaRow: 1, deltaColumn: 0 });
|
||||||
|
// } else {
|
||||||
|
// options.onMoveCaretToEnd();
|
||||||
|
// }
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// case 'Tab': {
|
||||||
|
// if (event.shiftKey) {
|
||||||
|
// options.onEditEnd({ deltaRow: 0, deltaColumn: -1 });
|
||||||
|
// } else {
|
||||||
|
// options.onEditEnd({ deltaRow: 0, deltaColumn: 1 });
|
||||||
|
// }
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// case 'Escape': {
|
||||||
|
// options.onEditEscape();
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// case 'ArrowLeft': {
|
||||||
|
// if (mode === 'init') {
|
||||||
|
// options.onEditEnd({ deltaRow: 0, deltaColumn: -1 });
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// case 'ArrowRight': {
|
||||||
|
// if (mode === 'init') {
|
||||||
|
// options.onEditEnd({ deltaRow: 0, deltaColumn: 1 });
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// case 'F4': {
|
||||||
|
// options.onReferenceCycle();
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [model, state]);
|
||||||
|
return { onKeyDown };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useEditorKeydown;
|
||||||
334
webapp/src/components/editor/util.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { getTokens } from "@ironcalc/wasm";
|
||||||
|
import { tokenIsRangeType, tokenIsReferenceType } from "./tokenTypes";
|
||||||
|
import { Area } from "./editorContext";
|
||||||
|
|
||||||
|
const letters = [
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
"C",
|
||||||
|
"D",
|
||||||
|
"E",
|
||||||
|
"F",
|
||||||
|
"G",
|
||||||
|
"H",
|
||||||
|
"I",
|
||||||
|
"J",
|
||||||
|
"K",
|
||||||
|
"L",
|
||||||
|
"M",
|
||||||
|
"N",
|
||||||
|
"O",
|
||||||
|
"P",
|
||||||
|
"Q",
|
||||||
|
"R",
|
||||||
|
"S",
|
||||||
|
"T",
|
||||||
|
"U",
|
||||||
|
"V",
|
||||||
|
"W",
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
];
|
||||||
|
interface Reference {
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
absoluteRow: boolean;
|
||||||
|
absoluteColumn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function referenceToString(rf: Reference): string {
|
||||||
|
const absC = rf.absoluteColumn ? "$" : "";
|
||||||
|
const absR = rf.absoluteRow ? "$" : "";
|
||||||
|
return absC + columnNameFromNumber(rf.column) + absR + rf.row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function columnNameFromNumber(column: number): string {
|
||||||
|
let columnName = "";
|
||||||
|
let index = column;
|
||||||
|
while (index > 0) {
|
||||||
|
columnName = `${letters[(index - 1) % 26]}${columnName}`;
|
||||||
|
index = Math.floor((index - 1) / 26);
|
||||||
|
}
|
||||||
|
return columnName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function columnNumberFromName(columnName: string): number {
|
||||||
|
let column = 0;
|
||||||
|
for (const character of columnName) {
|
||||||
|
const index = (character.codePointAt(0) ?? 0) - 64;
|
||||||
|
column = column * 26 + index;
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
interface Range {
|
||||||
|
sheet: number | null;
|
||||||
|
rowStart: number;
|
||||||
|
rowEnd: number;
|
||||||
|
columnStart: number;
|
||||||
|
columnEnd: number;
|
||||||
|
absoluteRowStart: boolean;
|
||||||
|
absoluteRowEnd: boolean;
|
||||||
|
absoluteColumnStart: boolean;
|
||||||
|
absoluteColumnEnd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStringRange(range: Range, sheetNames: string[]) {
|
||||||
|
const name = range.sheet ? `${sheetNames[range.sheet]}!` : "";
|
||||||
|
const left = referenceToString({
|
||||||
|
row: range.rowStart,
|
||||||
|
column: range.columnStart,
|
||||||
|
absoluteRow: range.absoluteRowStart,
|
||||||
|
absoluteColumn: range.absoluteColumnStart,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
range.rowStart === range.rowEnd &&
|
||||||
|
range.columnStart === range.columnEnd
|
||||||
|
) {
|
||||||
|
return `${name}${left}`;
|
||||||
|
}
|
||||||
|
const right = referenceToString({
|
||||||
|
row: range.rowEnd,
|
||||||
|
column: range.columnEnd,
|
||||||
|
absoluteRow: range.absoluteRowEnd,
|
||||||
|
absoluteColumn: range.absoluteColumnEnd,
|
||||||
|
});
|
||||||
|
return `${name}${left}:${right}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveRange {
|
||||||
|
sheet: number;
|
||||||
|
rowStart: number;
|
||||||
|
rowEnd: number;
|
||||||
|
columnStart: number;
|
||||||
|
columnEnd: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* This function get a formula like `=A1*SUM(B5:C6)` and transforms it to:
|
||||||
|
*
|
||||||
|
* `<span>=</span><span>A1</span><span>SUM</span><span>(</span><span>B5:C6</span><span>)</span>`
|
||||||
|
*
|
||||||
|
* While also returning the set of ranges [A1, B5:C6] with specific color assignments for each range
|
||||||
|
*/
|
||||||
|
export function getFormulaHTML(
|
||||||
|
text: string,
|
||||||
|
sheet: number,
|
||||||
|
sheetList: string[],
|
||||||
|
insertRage: Area | null,
|
||||||
|
insertRangeText: string
|
||||||
|
): {
|
||||||
|
html: JSX.Element[];
|
||||||
|
activeRanges: ActiveRange[];
|
||||||
|
isInReferenceMode: boolean;
|
||||||
|
} {
|
||||||
|
let html = [];
|
||||||
|
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> = {};
|
||||||
|
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>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokenCount > 0) {
|
||||||
|
const lastToken = tokens[tokens.length - 1];
|
||||||
|
if (lastToken.end < text.length - 1) {
|
||||||
|
html.push(
|
||||||
|
<span key="rest">{text.slice(lastToken.end + 1, text.length)}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html = [<span key="equals">=</span>].concat(html);
|
||||||
|
} else {
|
||||||
|
html = [<span key="single">{text}</span>];
|
||||||
|
}
|
||||||
|
const isRefMode = isInReferenceMode(text, text.length);
|
||||||
|
if (isRefMode) {
|
||||||
|
if (insertRage) {
|
||||||
|
const color = getColor(colorCount);
|
||||||
|
activeRanges.push({
|
||||||
|
sheet: insertRage.sheet || sheet,
|
||||||
|
rowStart: insertRage.rowStart,
|
||||||
|
rowEnd: insertRage.rowEnd,
|
||||||
|
columnStart: insertRage.columnStart,
|
||||||
|
columnEnd: insertRage.columnEnd,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
colorCount += 1;
|
||||||
|
html.push(
|
||||||
|
<span key="insert-range" style={{ color, textDecoration: "underline" }}>
|
||||||
|
{insertRangeText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
html.push(
|
||||||
|
<span
|
||||||
|
key="insert-cue"
|
||||||
|
style={{
|
||||||
|
border: "1px solid #d5d5d5",
|
||||||
|
height: "2px",
|
||||||
|
width: "7px",
|
||||||
|
borderTop: 0,
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We add a clickable element that spans the rest of the available space
|
||||||
|
html.push(<span key="spacer" style={{ flexGrow: 1 }}></span>);
|
||||||
|
return { html, activeRanges, isInReferenceMode: isRefMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
109
webapp/src/components/formatMenu.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState, useRef, ComponentProps } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { NumberFormats } from './formatUtil';
|
||||||
|
import { Menu, MenuItem, styled } from '@mui/material';
|
||||||
|
import FormatPicker from './formatPicker';
|
||||||
|
|
||||||
|
type FormatMenuProps = {
|
||||||
|
children: any; //ReactI18NextChild | Iterable<ReactI18NextChild>;
|
||||||
|
numFmt: string;
|
||||||
|
onChange: (numberFmt: string) => void;
|
||||||
|
onExited?: () => void;
|
||||||
|
anchorOrigin?: ComponentProps<typeof Menu>['anchorOrigin'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormatMenu = (properties: FormatMenuProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { onChange } = properties;
|
||||||
|
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [isPickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const anchorElement = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ChildrenWrapper onClick={(): void => setMenuOpen(true)} ref={anchorElement}>
|
||||||
|
{properties.children}
|
||||||
|
</ChildrenWrapper>
|
||||||
|
<Menu
|
||||||
|
open={isMenuOpen}
|
||||||
|
onClose={(): void => setMenuOpen(false)}
|
||||||
|
// onExited={properties.onExited}
|
||||||
|
anchorEl={anchorElement.current}
|
||||||
|
anchorOrigin={properties.anchorOrigin}
|
||||||
|
>
|
||||||
|
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.AUTO)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.auto')}</MenuItemText>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
{/** TODO: Text option that transforms into plain text */}
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.NUMBER)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.number')}</MenuItemText>
|
||||||
|
<MenuItemExample>{t('toolbar.format_menu.number_example')}</MenuItemExample>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.PERCENTAGE)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.percentage')}</MenuItemText>
|
||||||
|
<MenuItemExample>{t('toolbar.format_menu.percentage_example')}</MenuItemExample>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_EUR)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.currency_eur')}</MenuItemText>
|
||||||
|
<MenuItemExample>{t('toolbar.format_menu.currency_eur_example')}</MenuItemExample>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_USD)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.currency_usd')}</MenuItemText>
|
||||||
|
<MenuItemExample>{t('toolbar.format_menu.currency_usd_example')}</MenuItemExample>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_GBP)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.currency_gbp')}</MenuItemText>
|
||||||
|
<MenuItemExample>{t('toolbar.format_menu.currency_gbp_example')}</MenuItemExample>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.DATE_SHORT)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.date_short')}</MenuItemText>
|
||||||
|
<MenuItemExample>{t('toolbar.format_menu.date_short_example')}</MenuItemExample>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.DATE_LONG)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.date_long')}</MenuItemText>
|
||||||
|
<MenuItemExample>{t('toolbar.format_menu.date_long_example')}</MenuItemExample>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItemWrapper onClick={(): void => setPickerOpen(true)}>
|
||||||
|
<MenuItemText>{t('toolbar.format_menu.custom')}</MenuItemText>
|
||||||
|
</MenuItemWrapper>
|
||||||
|
</Menu>
|
||||||
|
<FormatPicker
|
||||||
|
numFmt={properties.numFmt}
|
||||||
|
onChange={properties.onChange}
|
||||||
|
open={isPickerOpen}
|
||||||
|
onClose={(): void => setPickerOpen(false)}
|
||||||
|
onExited={properties.onExited}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuItemWrapper = styled(MenuItem)`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChildrenWrapper = styled('div')`
|
||||||
|
display: flex;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MenuDivider = styled('div')``;
|
||||||
|
|
||||||
|
const MenuItemText = styled('div')`
|
||||||
|
color: #000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MenuItemExample = styled('div')`
|
||||||
|
margin-left: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default FormatMenu;
|
||||||
46
webapp/src/components/formatPicker.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type FormatPickerProps = {
|
||||||
|
className?: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onExited?: () => void;
|
||||||
|
numFmt: string;
|
||||||
|
onChange: (numberFmt: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormatPicker = (properties: FormatPickerProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [formatCode, setFormatCode] = useState(properties.numFmt);
|
||||||
|
|
||||||
|
const onSubmit = (format_code: string): void => {
|
||||||
|
properties.onChange(format_code);
|
||||||
|
properties.onClose();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={properties.open}
|
||||||
|
onClose={properties.onClose}
|
||||||
|
>
|
||||||
|
<DialogTitle>{t('num_fmt.title')}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<TextField
|
||||||
|
defaultValue={properties.numFmt}
|
||||||
|
label={t('num_fmt.label')}
|
||||||
|
name="format_code"
|
||||||
|
onChange={(event) => setFormatCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => onSubmit(formatCode)}>
|
||||||
|
{t('num_fmt.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default FormatPicker;
|
||||||
36
webapp/src/components/formatUtil.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
|
const newNumberFormat = numberFormat.replace(/\.0/g, '.00');
|
||||||
|
// If no decimal places declared, add 0.0
|
||||||
|
if (!newNumberFormat.includes('.')) {
|
||||||
|
if (newNumberFormat.includes('0')) {
|
||||||
|
return newNumberFormat.replace(/0/g, '0.0');
|
||||||
|
}
|
||||||
|
if (newNumberFormat.includes('#')) {
|
||||||
|
return newNumberFormat.replace(/#([^#,]|$)/g, '0.0$1');
|
||||||
|
}
|
||||||
|
return '0.0';
|
||||||
|
}
|
||||||
|
return newNumberFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decreaseDecimalPlaces(numberFormat: string): string {
|
||||||
|
// FIXME: Should it be done in the Rust? How should it work?
|
||||||
|
// Decrease decimal places for existing numbers with decimals
|
||||||
|
let newNumberFormat = numberFormat.replace(/\.0/g, '.');
|
||||||
|
// Fix leftover dots
|
||||||
|
newNumberFormat = newNumberFormat.replace(/0\.([^0]|$)/, '0$1');
|
||||||
|
return newNumberFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NumberFormats {
|
||||||
|
AUTO = 'general',
|
||||||
|
CURRENCY_EUR = '"€"#,##0.00',
|
||||||
|
CURRENCY_USD = '"$"#,##0.00',
|
||||||
|
CURRENCY_GBP = '"£"#,##0.00',
|
||||||
|
DATE_SHORT = 'dd"/"mm"/"yyyy',
|
||||||
|
DATE_LONG = 'dddd"," mmmm dd"," yyyy',
|
||||||
|
PERCENTAGE = '0.00%',
|
||||||
|
NUMBER = '#,##0.00',
|
||||||
|
}
|
||||||
108
webapp/src/components/formulabar.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Button, styled } from "@mui/material";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { Fx } from "../icons";
|
||||||
|
|
||||||
|
type FormulaBarProps = {
|
||||||
|
cellAddress: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formulaBarHeight = 30;
|
||||||
|
const headerColumnWidth = 30;
|
||||||
|
|
||||||
|
function FormulaBar(properties: FormulaBarProps) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<AddressContainer>
|
||||||
|
<CellBarAddress>{properties.cellAddress}</CellBarAddress>
|
||||||
|
<StyledButton>
|
||||||
|
<ChevronDown />
|
||||||
|
</StyledButton>
|
||||||
|
</AddressContainer>
|
||||||
|
<Divider />
|
||||||
|
<FormulaContainer>
|
||||||
|
<FormulaSymbolButton><Fx /></FormulaSymbolButton>
|
||||||
|
<Editor contentEditable="true" spellCheck="false" />
|
||||||
|
</FormulaContainer>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const StyledButton = styled(Button)`
|
||||||
|
width: 15px;
|
||||||
|
min-width: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
color: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FormulaSymbolButton = styled(StyledButton)`
|
||||||
|
margin-right: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Divider = styled("div")`
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
margin-left: 16px;
|
||||||
|
margin-right: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FormulaContainer = styled("div")`
|
||||||
|
margin-left: 10px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: normal;
|
||||||
|
width: 100%;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Container = styled("div")`
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background: ${(properties): string =>
|
||||||
|
properties.theme.palette.background.default};
|
||||||
|
height: ${formulaBarHeight}px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AddressContainer = styled("div")`
|
||||||
|
padding-left: 16px;
|
||||||
|
color: #333;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-grow: row;
|
||||||
|
min-width: ${headerColumnWidth}px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CellBarAddress = styled("div")`
|
||||||
|
width: 100%;
|
||||||
|
text-align: "center";
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Editor = styled("div")`
|
||||||
|
position: relative;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default FormulaBar;
|
||||||
2
webapp/src/components/navigation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './navigation';
|
||||||
|
export type { NavigationProps } from './navigation';
|
||||||
72
webapp/src/components/navigation/menus.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { styled } from "@mui/material";
|
||||||
|
import { SheetOptions } from "./types";
|
||||||
|
import Menu from "@mui/material/Menu";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
|
||||||
|
interface SheetListMenuProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
close: () => void;
|
||||||
|
anchorEl: HTMLButtonElement | null;
|
||||||
|
onSheetSelected: (index: number) => void;
|
||||||
|
sheetOptionsList: SheetOptions[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SheetListMenu = (properties: SheetListMenuProps) => {
|
||||||
|
const { isOpen, close, anchorEl, onSheetSelected, sheetOptionsList } =
|
||||||
|
properties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledMenu
|
||||||
|
open={isOpen}
|
||||||
|
onClose={close}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "left",
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sheetOptionsList.map((tab, index) => (
|
||||||
|
<StyledMenuItem
|
||||||
|
key={tab.sheetId}
|
||||||
|
onClick={(): void => onSheetSelected(index)}
|
||||||
|
>
|
||||||
|
<ItemColor style={{ backgroundColor: tab.color }} />
|
||||||
|
<ItemName>{tab.name}</ItemName>
|
||||||
|
</StyledMenuItem>
|
||||||
|
))}
|
||||||
|
</StyledMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledMenu = styled(Menu)({
|
||||||
|
"& .MuiPaper-root": {
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 4
|
||||||
|
},
|
||||||
|
"& .MuiList-padding": {
|
||||||
|
padding: 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const StyledMenuItem = styled(MenuItem)({
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ItemColor = styled("div")`
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ItemName = styled("div")`
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default SheetListMenu;
|
||||||
139
webapp/src/components/navigation/navigation.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { styled } from "@mui/material";
|
||||||
|
import { ChevronLeft, ChevronRight, Menu, Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SheetOptions } from "./types";
|
||||||
|
import SheetListMenu from "./menus";
|
||||||
|
import Sheet from "./sheet";
|
||||||
|
import { StyledButton } from "../toolbar";
|
||||||
|
|
||||||
|
export interface NavigationProps {
|
||||||
|
sheets: SheetOptions[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onSheetSelected: (index: number) => void;
|
||||||
|
onAddBlankSheet: () => void;
|
||||||
|
onSheetColorChanged: (hex: string) => void;
|
||||||
|
onSheetRenamed: (name: string) => void;
|
||||||
|
onSheetDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Navigation(props: NavigationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { onSheetSelected, sheets, selectedIndex } = props;
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<StyledButton title={t("navigation.add_sheet")} $pressed={false}>
|
||||||
|
<Plus />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton onClick={handleClick} title={t("navigation.sheet_list")} $pressed={false}>
|
||||||
|
<Menu />
|
||||||
|
</StyledButton>
|
||||||
|
<Sheets>
|
||||||
|
<SheetInner>
|
||||||
|
{sheets.map((tab, index) => (
|
||||||
|
<Sheet
|
||||||
|
key={tab.sheetId}
|
||||||
|
name={tab.name}
|
||||||
|
color={tab.color}
|
||||||
|
selected={index === selectedIndex}
|
||||||
|
onSelected={() => onSheetSelected(index)}
|
||||||
|
onColorChanged={function (hex: string): void {
|
||||||
|
console.log("Picked:", hex);
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}}
|
||||||
|
onRenamed={function (name: string): void {
|
||||||
|
console.log("Renamed:", name);
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}}
|
||||||
|
onDeleted={function (): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SheetInner>
|
||||||
|
</Sheets>
|
||||||
|
<LeftDivider />
|
||||||
|
<ChevronLeftStyled />
|
||||||
|
<ChevronRightStyled />
|
||||||
|
<RightDivider />
|
||||||
|
<Advert>ironcalc.com</Advert>
|
||||||
|
<SheetListMenu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
isOpen={open}
|
||||||
|
close={handleClose}
|
||||||
|
sheetOptionsList={sheets}
|
||||||
|
onSheetSelected={onSheetSelected}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
const Container = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0px;
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
display: flex;
|
||||||
|
height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 12px;
|
||||||
|
font-family: Inter;
|
||||||
|
background-color: #fff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Sheets = styled("div")`
|
||||||
|
flex-grow: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SheetInner = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LeftDivider = styled("div")`
|
||||||
|
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;
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Navigation;
|
||||||
83
webapp/src/components/navigation/sheet.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Button, Menu, MenuItem, styled } from "@mui/material";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
interface SheetProps {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
selected: boolean;
|
||||||
|
onSelected: () => void;
|
||||||
|
onColorChanged: (hex: string) => void;
|
||||||
|
onRenamed: (name: string) => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
function Sheet(props: SheetProps) {
|
||||||
|
const { name, color, selected, onSelected } = props;
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }}
|
||||||
|
onClick={onSelected}
|
||||||
|
>
|
||||||
|
<Name>{name}</Name>
|
||||||
|
<StyledButton onClick={handleOpen}>
|
||||||
|
<ChevronDown />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledMenu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "left",
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem>Rename</MenuItem>
|
||||||
|
<MenuItem>Change Color</MenuItem>
|
||||||
|
<MenuItem>Delete</MenuItem>
|
||||||
|
</StyledMenu>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledMenu = styled(Menu)``;
|
||||||
|
|
||||||
|
const StyledButton = styled(Button)`
|
||||||
|
width: 15px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
color: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Wrapper = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
margin-left: 20px;
|
||||||
|
border-bottom: 3px solid;
|
||||||
|
border-top: 3px solid white;
|
||||||
|
line-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Name = styled("div")`
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Sheet;
|
||||||
5
webapp/src/components/navigation/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface SheetOptions {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
sheetId: number;
|
||||||
|
}
|
||||||
430
webapp/src/components/toolbar.tsx
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AlignLeft,
|
||||||
|
AlignRight,
|
||||||
|
Bold,
|
||||||
|
ChevronDown,
|
||||||
|
Euro,
|
||||||
|
Italic,
|
||||||
|
PaintBucket,
|
||||||
|
Paintbrush2,
|
||||||
|
Percent,
|
||||||
|
Redo2,
|
||||||
|
Strikethrough,
|
||||||
|
Underline,
|
||||||
|
Undo2,
|
||||||
|
Grid2X2,
|
||||||
|
Type,
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpToLine,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import ColorPicker from "./colorPicker";
|
||||||
|
import BorderPicker from "./borderPicker";
|
||||||
|
import {
|
||||||
|
ArrowMiddleFromLine,
|
||||||
|
DecimalPlacesDecreaseIcon,
|
||||||
|
DecimalPlacesIncreaseIcon,
|
||||||
|
} from "../icons";
|
||||||
|
import {
|
||||||
|
NumberFormats,
|
||||||
|
decreaseDecimalPlaces,
|
||||||
|
increaseDecimalPlaces,
|
||||||
|
} from "./formatUtil";
|
||||||
|
import FormatMenu from "./formatMenu";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
import {
|
||||||
|
BorderOptions,
|
||||||
|
HorizontalAlignment,
|
||||||
|
VerticalAlignment,
|
||||||
|
} from "@ironcalc/wasm";
|
||||||
|
|
||||||
|
type ToolbarProperties = {
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
onRedo: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onToggleUnderline: (u: boolean) => void;
|
||||||
|
onToggleBold: (v: boolean) => void;
|
||||||
|
onToggleItalic: (v: boolean) => void;
|
||||||
|
onToggleStrike: (v: boolean) => void;
|
||||||
|
onToggleHorizontalAlign: (v: string) => void;
|
||||||
|
onToggleVerticalAlign: (v: string) => void;
|
||||||
|
onCopyStyles: () => void;
|
||||||
|
onTextColorPicked: (hex: string) => void;
|
||||||
|
onFillColorPicked: (hex: string) => void;
|
||||||
|
onNumberFormatPicked: (numberFmt: string) => void;
|
||||||
|
onBorderChanged: (border: BorderOptions) => void;
|
||||||
|
fillColor: string;
|
||||||
|
fontColor: string;
|
||||||
|
bold: boolean;
|
||||||
|
underline: boolean;
|
||||||
|
italic: boolean;
|
||||||
|
strike: boolean;
|
||||||
|
horizontalAlign: HorizontalAlignment;
|
||||||
|
verticalAlign: VerticalAlignment;
|
||||||
|
canEdit: boolean;
|
||||||
|
numFmt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Toolbar(properties: ToolbarProperties) {
|
||||||
|
const [fontColorPickerOpen, setFontColorPickerOpen] = useState(false);
|
||||||
|
const [fillColorPickerOpen, setFillColorPickerOpen] = useState(false);
|
||||||
|
const [borderPickerOpen, setBorderPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const fontColorButton = useRef(null);
|
||||||
|
const fillColorButton = useRef(null);
|
||||||
|
const borderButton = useRef(null);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { canEdit } = properties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarContainer>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
onClick={properties.onUndo}
|
||||||
|
disabled={!properties.canUndo}
|
||||||
|
title={t("toolbar.undo")}
|
||||||
|
>
|
||||||
|
<Undo2 />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
onClick={properties.onRedo}
|
||||||
|
disabled={!properties.canRedo}
|
||||||
|
title={t("toolbar.redo")}
|
||||||
|
>
|
||||||
|
<Redo2 />
|
||||||
|
</StyledButton>
|
||||||
|
<Divider />
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
onClick={properties.onCopyStyles}
|
||||||
|
title={t("toolbar.copy_styles")}
|
||||||
|
>
|
||||||
|
<Paintbrush2 />
|
||||||
|
</StyledButton>
|
||||||
|
<Divider />
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
onClick={(): void => {
|
||||||
|
properties.onNumberFormatPicked(NumberFormats.CURRENCY_EUR);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.euro")}
|
||||||
|
>
|
||||||
|
<Euro />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
onClick={(): void => {
|
||||||
|
properties.onNumberFormatPicked(NumberFormats.PERCENTAGE);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.percentage")}
|
||||||
|
>
|
||||||
|
<Percent />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
onClick={(): void => {
|
||||||
|
properties.onNumberFormatPicked(
|
||||||
|
decreaseDecimalPlaces(properties.numFmt)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.decimal_places_decrease")}
|
||||||
|
>
|
||||||
|
<DecimalPlacesDecreaseIcon />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
onClick={(): void => {
|
||||||
|
properties.onNumberFormatPicked(
|
||||||
|
increaseDecimalPlaces(properties.numFmt)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.decimal_places_increase")}
|
||||||
|
>
|
||||||
|
<DecimalPlacesIncreaseIcon />
|
||||||
|
</StyledButton>
|
||||||
|
<FormatMenu
|
||||||
|
numFmt={properties.numFmt}
|
||||||
|
onChange={(numberFmt): void => {
|
||||||
|
properties.onNumberFormatPicked(numberFmt);
|
||||||
|
}}
|
||||||
|
onExited={(): void => {}}
|
||||||
|
anchorOrigin={{
|
||||||
|
horizontal: 20, // Aligning the menu to the middle of FormatButton
|
||||||
|
vertical: "bottom",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.format_number")}
|
||||||
|
sx={{
|
||||||
|
width: "40px", // Keep in sync with anchorOrigin in FormatMenu above
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{"123"}
|
||||||
|
<ChevronDown />
|
||||||
|
</StyledButton>
|
||||||
|
</FormatMenu>
|
||||||
|
<Divider />
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.bold}
|
||||||
|
onClick={() => properties.onToggleBold(!properties.bold)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.bold")}
|
||||||
|
>
|
||||||
|
<Bold />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.italic}
|
||||||
|
onClick={() => properties.onToggleItalic(!properties.italic)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.italic")}
|
||||||
|
>
|
||||||
|
<Italic />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.underline}
|
||||||
|
onClick={() => properties.onToggleUnderline(!properties.underline)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.underline")}
|
||||||
|
>
|
||||||
|
<Underline />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.strike}
|
||||||
|
onClick={() => properties.onToggleStrike(!properties.strike)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.strike_trough")}
|
||||||
|
>
|
||||||
|
<Strikethrough />
|
||||||
|
</StyledButton>
|
||||||
|
<Divider />
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.font_color")}
|
||||||
|
ref={fontColorButton}
|
||||||
|
$underlinedColor={properties.fontColor}
|
||||||
|
onClick={() => setFontColorPickerOpen(true)}
|
||||||
|
>
|
||||||
|
<Type />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.fill_color")}
|
||||||
|
ref={fillColorButton}
|
||||||
|
$underlinedColor={properties.fillColor}
|
||||||
|
onClick={() => setFillColorPickerOpen(true)}
|
||||||
|
>
|
||||||
|
<PaintBucket />
|
||||||
|
</StyledButton>
|
||||||
|
<Divider />
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.horizontalAlign === "left"}
|
||||||
|
onClick={() =>
|
||||||
|
properties.onToggleHorizontalAlign(
|
||||||
|
properties.horizontalAlign === "left" ? "general" : "left"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.align_left")}
|
||||||
|
>
|
||||||
|
<AlignLeft />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.horizontalAlign === "center"}
|
||||||
|
onClick={() =>
|
||||||
|
properties.onToggleHorizontalAlign(
|
||||||
|
properties.horizontalAlign === "center" ? "general" : "center"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.align_center")}
|
||||||
|
>
|
||||||
|
<AlignCenter />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.horizontalAlign === "right"}
|
||||||
|
onClick={() =>
|
||||||
|
properties.onToggleHorizontalAlign(
|
||||||
|
properties.horizontalAlign === "right" ? "general" : "right"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.align_right")}
|
||||||
|
>
|
||||||
|
<AlignRight />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.verticalAlign === "top"}
|
||||||
|
onClick={() =>
|
||||||
|
properties.onToggleVerticalAlign(
|
||||||
|
properties.verticalAlign === "top" ? "bottom" : "top"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.vertical_align_top")}
|
||||||
|
>
|
||||||
|
<ArrowUpToLine />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.verticalAlign === "center"}
|
||||||
|
onClick={() =>
|
||||||
|
properties.onToggleVerticalAlign(
|
||||||
|
properties.verticalAlign === "center" ? "bottom" : "center"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.vertical_align_center")}
|
||||||
|
>
|
||||||
|
<ArrowMiddleFromLine />
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={properties.verticalAlign === "bottom"}
|
||||||
|
onClick={() => properties.onToggleVerticalAlign("bottom")}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.vertical_align_bottom")}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine />
|
||||||
|
</StyledButton>
|
||||||
|
<Divider />
|
||||||
|
<StyledButton
|
||||||
|
type="button"
|
||||||
|
$pressed={false}
|
||||||
|
onClick={() => setBorderPickerOpen(true)}
|
||||||
|
ref={borderButton}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={t("toolbar.borders")}
|
||||||
|
>
|
||||||
|
<Grid2X2 />
|
||||||
|
</StyledButton>
|
||||||
|
<ColorPicker
|
||||||
|
color={properties.fontColor}
|
||||||
|
onChange={(color): void => {
|
||||||
|
properties.onTextColorPicked(color);
|
||||||
|
setFontColorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
anchorEl={fontColorButton}
|
||||||
|
open={fontColorPickerOpen}
|
||||||
|
/>
|
||||||
|
<ColorPicker
|
||||||
|
color={properties.fillColor}
|
||||||
|
onChange={(color): void => {
|
||||||
|
properties.onFillColorPicked(color);
|
||||||
|
setFillColorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
anchorEl={fillColorButton}
|
||||||
|
open={fillColorPickerOpen}
|
||||||
|
/>
|
||||||
|
<BorderPicker
|
||||||
|
onChange={(border): void => {
|
||||||
|
properties.onBorderChanged(border);
|
||||||
|
setBorderPickerOpen(false);
|
||||||
|
}}
|
||||||
|
anchorEl={borderButton}
|
||||||
|
open={borderPickerOpen}
|
||||||
|
/>
|
||||||
|
</ToolbarContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const toolbarHeight = 40;
|
||||||
|
|
||||||
|
const ToolbarContainer = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
background: ${({ theme }) => theme.palette.background.paper};
|
||||||
|
height: ${toolbarHeight}px;
|
||||||
|
line-height: ${toolbarHeight}px;
|
||||||
|
border-bottom: 1px solid ${({}) => theme.palette.grey["600"]};
|
||||||
|
font-family: Inter;
|
||||||
|
border-radius: 4px 4px 0px 0px;
|
||||||
|
overflow-x: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
|
||||||
|
export const StyledButton = styled("button")<TypeButtonProperties>(
|
||||||
|
({ disabled, $pressed, $underlinedColor }) => {
|
||||||
|
let result: Record<string, any> = {
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "26px",
|
||||||
|
border: "0px solid #fff",
|
||||||
|
borderRadius: "2px",
|
||||||
|
marginRight: "5px",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
cursor: "pointer",
|
||||||
|
backgroundColor: "white",
|
||||||
|
padding: "0px",
|
||||||
|
};
|
||||||
|
if (disabled) {
|
||||||
|
result.color = theme.palette.grey["600"];
|
||||||
|
result.cursor = "default";
|
||||||
|
} else {
|
||||||
|
result.borderTop = $underlinedColor ? "3px solid #FFF" : "none";
|
||||||
|
result.borderBottom = $underlinedColor
|
||||||
|
? `3px solid ${$underlinedColor}`
|
||||||
|
: "none";
|
||||||
|
(result.color = "#21243A"), //theme.palette.text.primary;
|
||||||
|
(result.backgroundColor = $pressed
|
||||||
|
? theme.palette.grey["600"]
|
||||||
|
: "#FFF");
|
||||||
|
result["&:hover"] = {
|
||||||
|
backgroundColor: "#F1F2F8",
|
||||||
|
borderTopColor: "#F1F2F8",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
result["svg"] = {
|
||||||
|
width: "16px",
|
||||||
|
height: "16px",
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Divider = styled("div")({
|
||||||
|
width: "0px",
|
||||||
|
height: "10px",
|
||||||
|
borderLeft: "1px solid #D3D6E9",
|
||||||
|
marginLeft: "5px",
|
||||||
|
marginRight: "10px",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Toolbar;
|
||||||
178
webapp/src/components/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useCallback, KeyboardEvent, RefObject } from 'react';
|
||||||
|
import { isEditingKey, isNavigationKey, NavigationKey } from './WorksheetCanvas/util';
|
||||||
|
|
||||||
|
export enum Border {
|
||||||
|
Top = 'top',
|
||||||
|
Bottom = 'bottom',
|
||||||
|
Right = 'right',
|
||||||
|
Left = 'left',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
onCellsDeleted: () => void;
|
||||||
|
onExpandAreaSelectedKeyboard: (key: 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown') => void;
|
||||||
|
onEditKeyPressStart: (initText: string) => void;
|
||||||
|
onCellEditStart: () => void;
|
||||||
|
onBold: () => void;
|
||||||
|
onItalic: () => void;
|
||||||
|
onUnderline: () => void;
|
||||||
|
onNavigationToEdge: (direction: NavigationKey) => void;
|
||||||
|
onPageDown: () => void;
|
||||||
|
onPageUp: () => void;
|
||||||
|
onArrowDown: () => void;
|
||||||
|
onArrowUp: () => void;
|
||||||
|
onArrowLeft: () => void;
|
||||||
|
onArrowRight: () => void;
|
||||||
|
onKeyHome: () => void;
|
||||||
|
onKeyEnd: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
root: RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useKeyboardNavigation = (options: Options): { onKeyDown: (event: KeyboardEvent) => void } => {
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
const { key } = event;
|
||||||
|
const { root } = options;
|
||||||
|
// Silence the linter
|
||||||
|
if (!root.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target !== root.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.metaKey || event.ctrlKey) {
|
||||||
|
switch (key) {
|
||||||
|
case 'z': {
|
||||||
|
options.onUndo();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'y': {
|
||||||
|
options.onRedo();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'b': {
|
||||||
|
options.onBold();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'i': {
|
||||||
|
options.onItalic();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'u': {
|
||||||
|
options.onUnderline();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// No default
|
||||||
|
}
|
||||||
|
if (isNavigationKey(key)) {
|
||||||
|
options.onNavigationToEdge(key);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'F2') {
|
||||||
|
options.onCellEditStart();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEditingKey(key) || key === 'Backspace') {
|
||||||
|
const initText = key === 'Backspace' ? '' : key;
|
||||||
|
options.onEditKeyPressStart(initText);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Worksheet Navigation
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (
|
||||||
|
key === 'ArrowRight' ||
|
||||||
|
key === 'ArrowLeft' ||
|
||||||
|
key === 'ArrowUp' ||
|
||||||
|
key === 'ArrowDown'
|
||||||
|
) {
|
||||||
|
options.onExpandAreaSelectedKeyboard(key);
|
||||||
|
} else if (key === 'Tab') {
|
||||||
|
options.onArrowLeft();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'Tab': {
|
||||||
|
options.onArrowRight();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
options.onArrowLeft();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'Enter': {
|
||||||
|
options.onArrowDown();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
options.onArrowUp();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'End': {
|
||||||
|
options.onKeyEnd();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Home': {
|
||||||
|
options.onKeyHome();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Delete': {
|
||||||
|
options.onCellsDeleted();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'PageDown': {
|
||||||
|
options.onPageDown();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'PageUp': {
|
||||||
|
options.onPageUp();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// No default
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
return { onKeyDown };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useKeyboardNavigation;
|
||||||
223
webapp/src/components/usePointer.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useCallback, RefObject, PointerEvent, useRef } from 'react';
|
||||||
|
import WorksheetCanvas, { headerColumnWidth, headerRowHeight } from './WorksheetCanvas/worksheetCanvas';
|
||||||
|
import { Cell } from './WorksheetCanvas/util';
|
||||||
|
|
||||||
|
interface PointerSettings {
|
||||||
|
canvasElement: RefObject<HTMLCanvasElement>;
|
||||||
|
worksheetCanvas: RefObject<WorksheetCanvas | null>;
|
||||||
|
worksheetElement: RefObject<HTMLDivElement>;
|
||||||
|
// rowContextMenuAnchorElement: RefObject<HTMLDivElement>;
|
||||||
|
// columnContextMenuAnchorElement: RefObject<HTMLDivElement>;
|
||||||
|
onCellSelected: (cell: Cell, event: React.MouseEvent) => void;
|
||||||
|
onAreaSelecting: (cell: Cell) => void;
|
||||||
|
onExtendToCell: (cell: Cell) => void;
|
||||||
|
onExtendToEnd: () => void;
|
||||||
|
// onRowContextMenu: (row: number) => void;
|
||||||
|
// onColumnContextMenu: (column: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PointerEvents {
|
||||||
|
onPointerDown: (event: PointerEvent) => void;
|
||||||
|
onPointerMove: (event: PointerEvent) => void;
|
||||||
|
onPointerUp: (event: PointerEvent) => void;
|
||||||
|
onPointerHandleDown: (event: PointerEvent) => void;
|
||||||
|
// onContextMenu: (event: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usePointer = (options: PointerSettings): PointerEvents => {
|
||||||
|
const isSelecting = useRef(false);
|
||||||
|
const isExtending = useRef(false);
|
||||||
|
|
||||||
|
// const onContextMenu = useCallback(
|
||||||
|
// (event: React.MouseEvent): void => {
|
||||||
|
// let x = event.clientX;
|
||||||
|
// let y = event.clientY;
|
||||||
|
// const {
|
||||||
|
// canvasElement,
|
||||||
|
// worksheetElement,
|
||||||
|
// worksheetCanvas,
|
||||||
|
// onRowContextMenu,
|
||||||
|
// rowContextMenuAnchorElement,
|
||||||
|
// onColumnContextMenu,
|
||||||
|
// columnContextMenuAnchorElement,
|
||||||
|
// } = options;
|
||||||
|
// const worksheet = worksheetCanvas.current;
|
||||||
|
// const canvas = canvasElement.current;
|
||||||
|
// const worksheetWrapper = worksheetElement.current;
|
||||||
|
// // Silence the linter
|
||||||
|
// if (!canvas || !worksheet || !worksheetWrapper) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
// x -= canvasRect.x;
|
||||||
|
// y -= canvasRect.y;
|
||||||
|
// const menuAnchorOffsetY = 10;
|
||||||
|
// if (x > 0 && x < headerColumnWidth && y > headerRowHeight && y < canvasRect.height) {
|
||||||
|
// // Click on a row number
|
||||||
|
// const cell = worksheet.getCellByCoordinates(headerColumnWidth, y);
|
||||||
|
// if (cell) {
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
// if (rowContextMenuAnchorElement.current) {
|
||||||
|
// const scrollPosition = worksheet.getScrollPosition();
|
||||||
|
// rowContextMenuAnchorElement.current.style.left = `${x + scrollPosition.left}px`;
|
||||||
|
// rowContextMenuAnchorElement.current.style.top = `${
|
||||||
|
// y + scrollPosition.top + menuAnchorOffsetY
|
||||||
|
// }px`;
|
||||||
|
// }
|
||||||
|
// options.onPointerDownAtCell(cell, event);
|
||||||
|
// onRowContextMenu(cell.row);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if (x > headerColumnWidth && x < canvas.width && y > 0 && y < headerRowHeight) {
|
||||||
|
// // Click on a column number
|
||||||
|
// const cell = worksheet.getCellByCoordinates(x, headerRowHeight);
|
||||||
|
// if (cell) {
|
||||||
|
// event.preventDefault();
|
||||||
|
// event.stopPropagation();
|
||||||
|
// if (columnContextMenuAnchorElement.current) {
|
||||||
|
// const scrollPosition = worksheet.getScrollPosition();
|
||||||
|
// columnContextMenuAnchorElement.current.style.left = `${x + scrollPosition.left}px`;
|
||||||
|
// columnContextMenuAnchorElement.current.style.top = `${
|
||||||
|
// y + scrollPosition.top + menuAnchorOffsetY
|
||||||
|
// }px`;
|
||||||
|
// }
|
||||||
|
// options.onPointerDownAtCell(cell, event);
|
||||||
|
// onColumnContextMenu(cell.column);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// [options],
|
||||||
|
// );
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(event: PointerEvent): void => {
|
||||||
|
// Range selections are disabled on non-mouse devices. Use touch move only
|
||||||
|
// to scroll for now.
|
||||||
|
if (event.pointerType !== 'mouse') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelecting.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) {
|
||||||
|
options.onAreaSelecting(cell);
|
||||||
|
}
|
||||||
|
} 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(
|
||||||
|
(event: PointerEvent): void => {
|
||||||
|
if (isSelecting.current) {
|
||||||
|
const { worksheetElement } = options;
|
||||||
|
isSelecting.current = false;
|
||||||
|
worksheetElement.current?.releasePointerCapture(event.pointerId);
|
||||||
|
} else if (isExtending.current) {
|
||||||
|
const { worksheetElement } = options;
|
||||||
|
isExtending.current = false;
|
||||||
|
worksheetElement.current?.releasePointerCapture(event.pointerId);
|
||||||
|
options.onExtendToEnd();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(event: PointerEvent) => {
|
||||||
|
let x = event.clientX;
|
||||||
|
let y = event.clientY;
|
||||||
|
const { canvasElement, worksheetElement, worksheetCanvas } = options;
|
||||||
|
const worksheet = worksheetCanvas.current;
|
||||||
|
const canvas = canvasElement.current;
|
||||||
|
const worksheetWrapper = worksheetElement.current;
|
||||||
|
// Silence the linter
|
||||||
|
if (!canvas || !worksheet || !worksheetWrapper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
x -= canvasRect.x;
|
||||||
|
y -= canvasRect.y;
|
||||||
|
// Makes sure is in the sheet area
|
||||||
|
if (
|
||||||
|
x > canvasRect.width ||
|
||||||
|
x < headerColumnWidth ||
|
||||||
|
y < headerRowHeight ||
|
||||||
|
y > canvasRect.height
|
||||||
|
) {
|
||||||
|
if (x > 0 && x < headerColumnWidth && y > headerRowHeight && y < canvasRect.height) {
|
||||||
|
// Click on a row number
|
||||||
|
const cell = worksheet.getCellByCoordinates(headerColumnWidth, y);
|
||||||
|
if (cell) {
|
||||||
|
// TODO
|
||||||
|
// Row selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cell = worksheet.getCellByCoordinates(x, y);
|
||||||
|
if (cell) {
|
||||||
|
options.onCellSelected(cell, event);
|
||||||
|
isSelecting.current = true;
|
||||||
|
worksheetWrapper.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerHandleDown = useCallback(
|
||||||
|
(event: PointerEvent) => {
|
||||||
|
const worksheetWrapper = options.worksheetElement.current;
|
||||||
|
// Silence the linter
|
||||||
|
if (!worksheetWrapper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isExtending.current = true;
|
||||||
|
worksheetWrapper.setPointerCapture(event.pointerId);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
onPointerHandleDown,
|
||||||
|
// onContextMenu,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePointer;
|
||||||
294
webapp/src/components/workbook.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import Toolbar from "./toolbar";
|
||||||
|
import FormulaBar from "./formulabar";
|
||||||
|
import Navigation from "./navigation/navigation";
|
||||||
|
import Worksheet from "./worksheet";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import useKeyboardNavigation from "./useKeyboardNavigation";
|
||||||
|
import { NavigationKey, getCellAddress } from "./WorksheetCanvas/util";
|
||||||
|
import { LAST_COLUMN, LAST_ROW } from "./WorksheetCanvas/constants";
|
||||||
|
import { WorkbookState } from "./workbookState";
|
||||||
|
import { BorderOptions, Model, WorksheetProperties } from "@ironcalc/wasm";
|
||||||
|
|
||||||
|
const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
|
||||||
|
const { model, workbookState } = props;
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [_redrawId, setRedrawId] = useState(0);
|
||||||
|
const info = model
|
||||||
|
.getWorksheetsProperties()
|
||||||
|
.map(({ name, color, sheet_id }: WorksheetProperties) => {
|
||||||
|
return { name, color: color ? color : "#FFF", sheetId: sheet_id };
|
||||||
|
});
|
||||||
|
|
||||||
|
const onRedo = () => {
|
||||||
|
model.redo();
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUndo = () => {
|
||||||
|
model.undo();
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRangeStyle = (stylePath: string, value: string) => {
|
||||||
|
const area = {
|
||||||
|
sheet: workbookState.getSelectedSheet(),
|
||||||
|
...workbookState.getSelectedArea(),
|
||||||
|
};
|
||||||
|
const range = {
|
||||||
|
sheet: area.sheet,
|
||||||
|
row: area.rowStart,
|
||||||
|
column: area.columnStart,
|
||||||
|
width: area.columnEnd - area.columnStart + 1,
|
||||||
|
height: area.rowEnd - area.rowStart + 1,
|
||||||
|
};
|
||||||
|
model.updateRangeStyle(range, stylePath, value);
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleUnderline = (value: boolean) => {
|
||||||
|
updateRangeStyle("font.u", `${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleItalic = (value: boolean) => {
|
||||||
|
updateRangeStyle("font.i", `${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleBold = (value: boolean) => {
|
||||||
|
updateRangeStyle("font.b", `${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleStrike = (value: boolean) => {
|
||||||
|
updateRangeStyle("font.strike", `${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleHorizontalAlign = (value: string) => {
|
||||||
|
updateRangeStyle("alignment.horizontal", value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleVerticalAlign = (value: string) => {
|
||||||
|
updateRangeStyle("alignment.vertical", value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTextColorPicked = (hex: string) => {
|
||||||
|
updateRangeStyle("font.color", hex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFillColorPicked = (hex: string) => {
|
||||||
|
updateRangeStyle("fill.fg_color", hex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNumberFormatPicked = (numberFmt: string) => {
|
||||||
|
updateRangeStyle("num_fmt", numberFmt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCopyStyles = () => {
|
||||||
|
const area = {
|
||||||
|
sheet: workbookState.getSelectedSheet(),
|
||||||
|
...workbookState.getSelectedArea(),
|
||||||
|
};
|
||||||
|
const styles = [];
|
||||||
|
for (let row = area.rowStart; row < area.rowEnd; row++) {
|
||||||
|
const styleRow = [];
|
||||||
|
for (let column = area.columnStart; column < area.columnEnd; column++) {
|
||||||
|
styleRow.push(model.getCellStyle(area.sheet, row, column));
|
||||||
|
}
|
||||||
|
styles.push(styleRow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { onKeyDown } = useKeyboardNavigation({
|
||||||
|
onCellsDeleted: function (): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onExpandAreaSelectedKeyboard: function (
|
||||||
|
key: "ArrowRight" | "ArrowLeft" | "ArrowUp" | "ArrowDown"
|
||||||
|
): void {
|
||||||
|
console.log(key);
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onEditKeyPressStart: function (initText: string): void {
|
||||||
|
console.log(initText);
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onCellEditStart: function (): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onBold: () => {
|
||||||
|
let sheet = workbookState.getSelectedSheet();
|
||||||
|
let { row, column } = workbookState.getSelectedCell();
|
||||||
|
let value = !model.getCellStyle(sheet, row, column).font.b;
|
||||||
|
onToggleBold(!value);
|
||||||
|
},
|
||||||
|
onItalic: () => {
|
||||||
|
let sheet = workbookState.getSelectedSheet();
|
||||||
|
let { row, column } = workbookState.getSelectedCell();
|
||||||
|
let value = !model.getCellStyle(sheet, row, column).font.i;
|
||||||
|
onToggleItalic(!value);
|
||||||
|
},
|
||||||
|
onUnderline: () => {
|
||||||
|
let sheet = workbookState.getSelectedSheet();
|
||||||
|
let { row, column } = workbookState.getSelectedCell();
|
||||||
|
let value = !model.getCellStyle(sheet, row, column).font.u;
|
||||||
|
onToggleUnderline(!value);
|
||||||
|
},
|
||||||
|
onNavigationToEdge: function (direction: NavigationKey): void {
|
||||||
|
console.log(direction);
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onPageDown: function (): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onPageUp: function (): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onArrowDown: function (): void {
|
||||||
|
const cell = workbookState.getSelectedCell();
|
||||||
|
const row = cell.row + 1;
|
||||||
|
if (row > LAST_ROW) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workbookState.selectCell({ row, column: cell.column });
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
},
|
||||||
|
onArrowUp: function (): void {
|
||||||
|
const cell = workbookState.getSelectedCell();
|
||||||
|
const row = cell.row - 1;
|
||||||
|
if (row < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workbookState.selectCell({ row, column: cell.column });
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
},
|
||||||
|
onArrowLeft: function (): void {
|
||||||
|
const cell = workbookState.getSelectedCell();
|
||||||
|
const column = cell.column - 1;
|
||||||
|
if (column < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workbookState.selectCell({ row: cell.row, column });
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
},
|
||||||
|
onArrowRight: function (): void {
|
||||||
|
const cell = workbookState.getSelectedCell();
|
||||||
|
const column = cell.column + 1;
|
||||||
|
if (column > LAST_COLUMN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workbookState.selectCell({ row: cell.row, column });
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
},
|
||||||
|
onKeyHome: function (): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onKeyEnd: function (): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
onUndo: function (): void {
|
||||||
|
model.undo();
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
},
|
||||||
|
onRedo: function (): void {
|
||||||
|
model.redo();
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
},
|
||||||
|
root: rootRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rootRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rootRef.current.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cellAddress = getCellAddress(
|
||||||
|
workbookState.getSelectedArea(),
|
||||||
|
workbookState.getSelectedCell()
|
||||||
|
);
|
||||||
|
|
||||||
|
const sheet = workbookState.getSelectedSheet();
|
||||||
|
const { row, column } = workbookState.getSelectedCell();
|
||||||
|
|
||||||
|
const style = model.getCellStyle(sheet, row, column);
|
||||||
|
console.log("data", sheet, row, column, style);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container ref={rootRef} onKeyDown={onKeyDown} tabIndex={0}>
|
||||||
|
<Toolbar
|
||||||
|
canUndo={model.canUndo()}
|
||||||
|
canRedo={model.canRedo()}
|
||||||
|
onRedo={onRedo}
|
||||||
|
onUndo={onUndo}
|
||||||
|
onToggleUnderline={onToggleUnderline}
|
||||||
|
onToggleBold={onToggleBold}
|
||||||
|
onToggleItalic={onToggleItalic}
|
||||||
|
onToggleStrike={onToggleStrike}
|
||||||
|
onToggleHorizontalAlign={onToggleHorizontalAlign}
|
||||||
|
onToggleVerticalAlign={onToggleVerticalAlign}
|
||||||
|
onCopyStyles={onCopyStyles}
|
||||||
|
onTextColorPicked={onTextColorPicked}
|
||||||
|
onFillColorPicked={onFillColorPicked}
|
||||||
|
onNumberFormatPicked={onNumberFormatPicked}
|
||||||
|
onBorderChanged={function (_border: BorderOptions): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}}
|
||||||
|
fillColor={style.fill.fg_color || "#FFF"}
|
||||||
|
fontColor={style.font.color}
|
||||||
|
bold={style.font.b}
|
||||||
|
underline={style.font.u}
|
||||||
|
italic={style.font.i}
|
||||||
|
strike={style.font.strike}
|
||||||
|
horizontalAlign={
|
||||||
|
style.alignment ? style.alignment.horizontal : "general"
|
||||||
|
}
|
||||||
|
verticalAlign={style.alignment ? style.alignment.vertical : "center"}
|
||||||
|
canEdit={true}
|
||||||
|
numFmt={""}
|
||||||
|
/>
|
||||||
|
<FormulaBar cellAddress={cellAddress} />
|
||||||
|
<Worksheet
|
||||||
|
model={model}
|
||||||
|
workbookState={workbookState}
|
||||||
|
refresh={(): void => {
|
||||||
|
setRedrawId((id) => id + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Navigation
|
||||||
|
sheets={info}
|
||||||
|
selectedIndex={workbookState.getSelectedSheet()}
|
||||||
|
onSheetSelected={function (sheet: number): void {
|
||||||
|
workbookState.setSelectedSheet(sheet);
|
||||||
|
setRedrawId((value) => value + 1);
|
||||||
|
}}
|
||||||
|
onAddBlankSheet={function (): void {
|
||||||
|
model.newSheet();
|
||||||
|
}}
|
||||||
|
onSheetColorChanged={function (hex: string): void {
|
||||||
|
console.log(hex);
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}}
|
||||||
|
onSheetRenamed={function (name: string): void {
|
||||||
|
console.log(name);
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}}
|
||||||
|
onSheetDeleted={function (): void {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Container = styled("div")`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
font-family: ${({ theme }) => theme.typography.fontFamily};
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Workbook;
|
||||||
62
webapp/src/components/workbookContext.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export interface Cell {
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Area {
|
||||||
|
rowStart: number;
|
||||||
|
rowEnd: number;
|
||||||
|
columnStart: number;
|
||||||
|
columnEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scroll {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusType = "cell" | "formula-bar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In Excel there are two "modes" of editing
|
||||||
|
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
|
||||||
|
* * `edit`: If you double click on a cell or click in the cell while editing.
|
||||||
|
* In this mode arrow keys will move within the cell.
|
||||||
|
*
|
||||||
|
* In a formula bar mode is always `edit`.
|
||||||
|
*/
|
||||||
|
type CellEditMode = "init" | "edit";
|
||||||
|
|
||||||
|
const WorkbookContext = createContext<{
|
||||||
|
selectedSheet: number;
|
||||||
|
selectedCell: Cell;
|
||||||
|
selectedArea: Area;
|
||||||
|
scroll: Scroll;
|
||||||
|
extendToArea: Area | null;
|
||||||
|
editor: Editor | null;
|
||||||
|
}>({
|
||||||
|
selectedSheet: 0,
|
||||||
|
selectedCell: {row: 1, column: 1},
|
||||||
|
selectedArea: {rowStart:1, rowEnd: 1, columnStart:1, columnEnd: 1},
|
||||||
|
scroll: {top: 0, left: 0},
|
||||||
|
extendToArea: null,
|
||||||
|
editor: null
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface Editor {
|
||||||
|
id: number;
|
||||||
|
sheet: number;
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
text: string;
|
||||||
|
base: string;
|
||||||
|
mode: CellEditMode;
|
||||||
|
focus: FocusType;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default WorkbookContext;
|
||||||
158
webapp/src/components/workbookState.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
export interface Cell {
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Area {
|
||||||
|
rowStart: number;
|
||||||
|
rowEnd: number;
|
||||||
|
columnStart: number;
|
||||||
|
columnEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scroll {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusType = 'cell' | 'formula-bar';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In Excel there are two "modes" of editing
|
||||||
|
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
|
||||||
|
* * `edit`: If you double click on a cell or click in the cell while editing.
|
||||||
|
* In this mode arrow keys will move within the cell.
|
||||||
|
*
|
||||||
|
* In a formula bar mode is always `edit`.
|
||||||
|
*/
|
||||||
|
type CellEditMode = 'init' | 'edit';
|
||||||
|
|
||||||
|
interface Editor {
|
||||||
|
id: number;
|
||||||
|
sheet: number;
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
text: string;
|
||||||
|
base: string;
|
||||||
|
mode: CellEditMode;
|
||||||
|
focus: FocusType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkbookState {
|
||||||
|
private selectedSheet: number;
|
||||||
|
private selectedCell: Cell;
|
||||||
|
private selectedArea: Area;
|
||||||
|
private scroll: Scroll;
|
||||||
|
private extendToArea: Area | null;
|
||||||
|
private editor: Editor | null;
|
||||||
|
private id;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const row = 1;
|
||||||
|
const column = 1;
|
||||||
|
const sheet = 0;
|
||||||
|
this.selectedSheet = sheet;
|
||||||
|
this.selectedCell = { row, column };
|
||||||
|
this.selectedArea = {
|
||||||
|
rowStart: row,
|
||||||
|
rowEnd: row,
|
||||||
|
columnStart: column,
|
||||||
|
columnEnd: column,
|
||||||
|
};
|
||||||
|
this.extendToArea = null;
|
||||||
|
this.scroll = {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
};
|
||||||
|
this.editor = null;
|
||||||
|
this.id = Math.floor(Math.random()*1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
startEditing(focus: FocusType, text: string) {
|
||||||
|
const {row, column} = this.selectedCell;
|
||||||
|
this.editor = {
|
||||||
|
id: 0,
|
||||||
|
sheet: this.selectedSheet,
|
||||||
|
row,
|
||||||
|
column,
|
||||||
|
base: '',
|
||||||
|
text,
|
||||||
|
mode: 'init',
|
||||||
|
focus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorText(text: string) {
|
||||||
|
if (!this.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.editor.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
endEditing() {
|
||||||
|
this.editor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEditor(): Editor | null {
|
||||||
|
console.log('getEditor', this.id);
|
||||||
|
return this.editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedSheet(): number {
|
||||||
|
return this.selectedSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedSheet(sheet: number): void {
|
||||||
|
this.selectedSheet = sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedCell(): Cell {
|
||||||
|
return this.selectedCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCell(cell: Cell): void {
|
||||||
|
this.selectedCell = cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedArea(): Area {
|
||||||
|
return this.selectedArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedArea(area: Area): void {
|
||||||
|
this.selectedArea = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectCell(cell: { row: number; column: number }): void {
|
||||||
|
console.log('selectCell: ', this.id)
|
||||||
|
const { row, column } = cell;
|
||||||
|
this.selectedArea = {
|
||||||
|
rowStart: row,
|
||||||
|
rowEnd: row,
|
||||||
|
columnStart: column,
|
||||||
|
columnEnd: column,
|
||||||
|
};
|
||||||
|
this.selectedCell = { row, column };
|
||||||
|
this.editor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getScroll(): Scroll {
|
||||||
|
return this.scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScroll(scroll: Scroll): void {
|
||||||
|
this.scroll = scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
getExtendToArea(): Area | null {
|
||||||
|
return this.extendToArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearExtendToArea(): void {
|
||||||
|
this.extendToArea = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtendToArea(area: Area): void {
|
||||||
|
this.extendToArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
436
webapp/src/components/worksheet.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
|
||||||
|
import {
|
||||||
|
outlineBackgroundColor,
|
||||||
|
outlineColor,
|
||||||
|
} from "./WorksheetCanvas/constants";
|
||||||
|
import usePointer from "./usePointer";
|
||||||
|
import { WorkbookState } from "./workbookState";
|
||||||
|
import { Cell } from "./WorksheetCanvas/types";
|
||||||
|
import Editor from "./editor";
|
||||||
|
import EditorContext, { EditorState } from "./editor/editorContext";
|
||||||
|
import { getFormulaHTML } from "./editor/util";
|
||||||
|
import { Model } from "@ironcalc/wasm";
|
||||||
|
|
||||||
|
function Worksheet(props: {
|
||||||
|
model: Model;
|
||||||
|
workbookState: WorkbookState;
|
||||||
|
refresh: () => void;
|
||||||
|
}) {
|
||||||
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
const worksheetElement = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollElement = useRef<HTMLDivElement>(null);
|
||||||
|
// const rootElement = useRef<HTMLDivElement>(null);
|
||||||
|
const spacerElement = useRef<HTMLDivElement>(null);
|
||||||
|
const cellOutline = useRef<HTMLDivElement>(null);
|
||||||
|
const areaOutline = useRef<HTMLDivElement>(null);
|
||||||
|
const cellOutlineHandle = useRef<HTMLDivElement>(null);
|
||||||
|
const extendToOutline = useRef<HTMLDivElement>(null);
|
||||||
|
const columnResizeGuide = useRef<HTMLDivElement>(null);
|
||||||
|
const rowResizeGuide = useRef<HTMLDivElement>(null);
|
||||||
|
// const contextMenuAnchorElement = useRef<HTMLDivElement>(null);
|
||||||
|
const columnHeaders = useRef<HTMLDivElement>(null);
|
||||||
|
const worksheetCanvas = useRef<WorksheetCanvas | null>(null);
|
||||||
|
|
||||||
|
const [isEditing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const [editorContext, setEditorContext] = useState<EditorState>({
|
||||||
|
mode: "accept",
|
||||||
|
insertRange: null,
|
||||||
|
baseText: '',
|
||||||
|
id: Math.floor(Math.random()*1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('worksheet', editorContext.id);
|
||||||
|
|
||||||
|
const { model, workbookState, refresh } = props;
|
||||||
|
useEffect(() => {
|
||||||
|
const canvasRef = canvasElement.current;
|
||||||
|
const columnGuideRef = columnResizeGuide.current;
|
||||||
|
const rowGuideRef = rowResizeGuide.current;
|
||||||
|
const columnHeadersRef = columnHeaders.current;
|
||||||
|
const worksheetRef = worksheetElement.current;
|
||||||
|
|
||||||
|
const outline = cellOutline.current;
|
||||||
|
const handle = cellOutlineHandle.current;
|
||||||
|
const area = areaOutline.current;
|
||||||
|
const extendTo = extendToOutline.current;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!canvasRef ||
|
||||||
|
!columnGuideRef ||
|
||||||
|
!rowGuideRef ||
|
||||||
|
!columnHeadersRef ||
|
||||||
|
!worksheetRef ||
|
||||||
|
!outline ||
|
||||||
|
!handle ||
|
||||||
|
!area ||
|
||||||
|
!extendTo
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const canvas = new WorksheetCanvas({
|
||||||
|
width: worksheetRef.clientWidth,
|
||||||
|
height: worksheetRef.clientHeight,
|
||||||
|
model,
|
||||||
|
workbookState,
|
||||||
|
elements: {
|
||||||
|
canvas: canvasRef,
|
||||||
|
columnGuide: columnGuideRef,
|
||||||
|
rowGuide: rowGuideRef,
|
||||||
|
columnHeaders: columnHeadersRef,
|
||||||
|
cellOutline: outline,
|
||||||
|
cellOutlineHandle: handle,
|
||||||
|
areaOutline: area,
|
||||||
|
extendToOutline: extendTo,
|
||||||
|
},
|
||||||
|
onColumnWidthChanges(sheet, column, width) {
|
||||||
|
model.setColumnWidth(sheet, column, width);
|
||||||
|
worksheetCanvas.current?.renderSheet();
|
||||||
|
},
|
||||||
|
onRowHeightChanges(sheet, row, height) {
|
||||||
|
model.setRowHeight(sheet, row, height);
|
||||||
|
worksheetCanvas.current?.renderSheet();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [sheetWidth, sheetHeight] = canvas.getSheetDimensions();
|
||||||
|
if (spacerElement.current) {
|
||||||
|
spacerElement.current.style.height = `${sheetHeight}px`;
|
||||||
|
spacerElement.current.style.width = `${sheetWidth}px`;
|
||||||
|
}
|
||||||
|
canvas.renderSheet();
|
||||||
|
worksheetCanvas.current = canvas;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sheetNames = model.getWorksheetsProperties().map((s: { name: string; }) => s.name);
|
||||||
|
|
||||||
|
const {
|
||||||
|
onPointerMove,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerHandleDown,
|
||||||
|
onPointerUp,
|
||||||
|
// onContextMenu,
|
||||||
|
} = usePointer({
|
||||||
|
onCellSelected: (cell: Cell, event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
workbookState.selectCell(cell);
|
||||||
|
// worksheetCanvas.current?.renderSheet();
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onAreaSelecting: (cell: Cell) => {
|
||||||
|
const canvas = worksheetCanvas.current;
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { row, column } = cell;
|
||||||
|
// const { width, height } = worksheet.getBoundingClientRect();
|
||||||
|
// const [x, y] = canvas.getCoordinatesByCell(row, column);
|
||||||
|
// const [x1, y1] = canvas.getCoordinatesByCell(row + 1, column + 1);
|
||||||
|
// const { left: canvasLeft, top: canvasTop } = canvas.getScrollPosition();
|
||||||
|
// // let border = Border.Right;
|
||||||
|
// // let { left, top } = state.scrollPosition;
|
||||||
|
// // if (x < headerColumnWidth) {
|
||||||
|
// // border = Border.Left;
|
||||||
|
// // left = canvasLeft - headerColumnWidth + x;
|
||||||
|
// // } else if (x1 > width - 20) {
|
||||||
|
// // border = Border.Right;
|
||||||
|
// // }
|
||||||
|
// // if (y < headerRowHeight) {
|
||||||
|
// // border = Border.Top;
|
||||||
|
// // top = canvasTop - headerRowHeight + y;
|
||||||
|
// // } else if (y1 > height - 20) {
|
||||||
|
// // border = Border.Bottom;
|
||||||
|
// // }
|
||||||
|
const selectedCell = workbookState.getSelectedCell();
|
||||||
|
const area = {
|
||||||
|
rowStart: Math.min(selectedCell.row, row),
|
||||||
|
rowEnd: Math.max(selectedCell.row, row),
|
||||||
|
columnStart: Math.min(selectedCell.column, column),
|
||||||
|
columnEnd: Math.max(selectedCell.column, column),
|
||||||
|
};
|
||||||
|
workbookState.setSelectedArea(area);
|
||||||
|
canvas.renderSheet();
|
||||||
|
// // If there are frozen rows or columns snap to origin if we cross boundaries
|
||||||
|
// const frozenRows = canvas.workbook.getFrozenRowsCount();
|
||||||
|
// const frozenColumns = canvas.workbook.getFrozenColumnsCount();
|
||||||
|
// if (area.rowStart <= frozenRows && area.rowEnd > frozenRows) {
|
||||||
|
// top = 0;
|
||||||
|
// }
|
||||||
|
// if (area.columnStart <= frozenColumns && area.columnEnd > frozenColumns) {
|
||||||
|
// left = 0;
|
||||||
|
// }
|
||||||
|
}, // editorActions.onPointerMoveToCell,
|
||||||
|
onExtendToCell: (cell) => {
|
||||||
|
const canvas = worksheetCanvas.current;
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { row, column } = cell;
|
||||||
|
const selectedCell = workbookState.getSelectedCell();
|
||||||
|
const area = {
|
||||||
|
rowStart: Math.min(selectedCell.row, row),
|
||||||
|
rowEnd: Math.max(selectedCell.row, row),
|
||||||
|
columnStart: Math.min(selectedCell.column, column),
|
||||||
|
columnEnd: Math.max(selectedCell.column, column),
|
||||||
|
};
|
||||||
|
workbookState.setExtendToArea(area);
|
||||||
|
canvas.renderSheet();
|
||||||
|
}, // editorActions.onExtendToCell,
|
||||||
|
onExtendToEnd: () => {
|
||||||
|
const canvas = worksheetCanvas.current;
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sheet = workbookState.getSelectedSheet();
|
||||||
|
const initialArea = workbookState.getSelectedArea();
|
||||||
|
const extendedArea = workbookState.getExtendToArea();
|
||||||
|
if (!extendedArea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// model.extendTo(sheet, initialArea, extendedArea);
|
||||||
|
workbookState.clearExtendToArea();
|
||||||
|
canvas.renderSheet();
|
||||||
|
}, // editorActions.onExtendToEnd,
|
||||||
|
canvasElement,
|
||||||
|
worksheetElement,
|
||||||
|
worksheetCanvas,
|
||||||
|
// rowContextMenuAnchorElement,
|
||||||
|
// columnContextMenuAnchorElement,
|
||||||
|
// onRowContextMenu,
|
||||||
|
// onColumnContextMenu,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onScroll = (): void => {
|
||||||
|
if (!scrollElement.current || !worksheetCanvas.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const left = scrollElement.current.scrollLeft;
|
||||||
|
const top = scrollElement.current.scrollTop;
|
||||||
|
|
||||||
|
worksheetCanvas.current.setScrollPosition({ left, top });
|
||||||
|
worksheetCanvas.current.renderSheet();
|
||||||
|
};
|
||||||
|
|
||||||
|
const {row, column} = workbookState.getSelectedCell();
|
||||||
|
const selectedSheet = workbookState.getSelectedSheet();
|
||||||
|
|
||||||
|
return (
|
||||||
|
// <EditorContext.Provider value={{editorContext}}>
|
||||||
|
<Wrapper ref={scrollElement} onScroll={onScroll}>
|
||||||
|
<Spacer ref={spacerElement} />
|
||||||
|
<SheetContainer
|
||||||
|
ref={worksheetElement}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (isEditing === true && editorContext.mode !== 'insert') {
|
||||||
|
setEditing(false);
|
||||||
|
model.setUserInput(selectedSheet, row, column, editorContext.baseText);
|
||||||
|
}
|
||||||
|
onPointerDown(event);
|
||||||
|
}}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
const sheet = workbookState.getSelectedSheet();
|
||||||
|
const {row, column} = workbookState.getSelectedCell();
|
||||||
|
const text = model.getCellContent(sheet, row, column) || '';
|
||||||
|
console.log('dbclick', text);
|
||||||
|
|
||||||
|
workbookState.startEditing("cell", `${text}`);
|
||||||
|
setEditorContext ((c: EditorState) => {
|
||||||
|
console.log('text', text, c.id);
|
||||||
|
return {
|
||||||
|
mode: c.mode,
|
||||||
|
insertRange: c.insertRange,
|
||||||
|
baseText: text,
|
||||||
|
dontChange: true,
|
||||||
|
id: c.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setEditing(true);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
// refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SheetCanvas ref={canvasElement} />
|
||||||
|
<CellOutline ref={cellOutline}>
|
||||||
|
{
|
||||||
|
<Editor
|
||||||
|
minimalWidth={200}
|
||||||
|
minimalHeight={90}
|
||||||
|
textColor="#333"
|
||||||
|
getStyledText={(text: string, insertRangeText: string) => {
|
||||||
|
return getFormulaHTML(
|
||||||
|
text,
|
||||||
|
0,
|
||||||
|
sheetNames,
|
||||||
|
editorContext.insertRange,
|
||||||
|
insertRangeText
|
||||||
|
);
|
||||||
|
} }
|
||||||
|
onEditEnd={(text: string) => {
|
||||||
|
console.log(text);
|
||||||
|
setEditing(false);
|
||||||
|
model.setUserInput(selectedSheet, row, column, text);
|
||||||
|
} }
|
||||||
|
originalText={model.getCellContent(selectedSheet, row, column) || ''}
|
||||||
|
display={isEditing}
|
||||||
|
cell={{ sheet: selectedSheet, row, column }}
|
||||||
|
sheetNames={sheetNames}
|
||||||
|
/>
|
||||||
|
/* <Editor
|
||||||
|
data-testid={WorkbookTestId.WorkbookCellEditor}
|
||||||
|
onEditChange={onEditChange}
|
||||||
|
onEditEnd={onEditEnd}
|
||||||
|
onEditEscape={onEditEscape}
|
||||||
|
onReferenceCycle={onReferenceCycle}
|
||||||
|
display={!!cellEditing}
|
||||||
|
focus={cellEditing?.focus === FocusType.Cell}
|
||||||
|
html={cellEditing?.html ?? ''}
|
||||||
|
cursorStart={cellEditing?.cursorStart ?? 0}
|
||||||
|
cursorEnd={cellEditing?.cursorEnd ?? 0}
|
||||||
|
mode={cellEditing?.mode ?? 'init'}
|
||||||
|
/> */
|
||||||
|
}
|
||||||
|
</CellOutline>
|
||||||
|
<AreaOutline ref={areaOutline} />
|
||||||
|
<ExtendToOutline ref={extendToOutline} />
|
||||||
|
<CellOutlineHandle
|
||||||
|
ref={cellOutlineHandle}
|
||||||
|
onPointerDown={onPointerHandleDown}
|
||||||
|
/>
|
||||||
|
<ColumnResizeGuide ref={columnResizeGuide} />
|
||||||
|
<RowResizeGuide ref={rowResizeGuide} />
|
||||||
|
<ColumnHeaders ref={columnHeaders} />
|
||||||
|
</SheetContainer>
|
||||||
|
</Wrapper>
|
||||||
|
// </EditorContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Spacer = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
height: 5000px;
|
||||||
|
width: 5000px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SheetContainer = styled("div")`
|
||||||
|
position: sticky;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
width: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
background: ${outlineColor};
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-resize-handle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.row-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
left: 0px;
|
||||||
|
height: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
background: ${outlineColor};
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-resize-handle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Wrapper = styled("div")({
|
||||||
|
position: "absolute",
|
||||||
|
overflow: "scroll",
|
||||||
|
top: 71,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 41,
|
||||||
|
});
|
||||||
|
|
||||||
|
const SheetCanvas = styled("canvas")`
|
||||||
|
position: relative;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
bottom: 40px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ColumnResizeGuide = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
display: none;
|
||||||
|
height: 100%;
|
||||||
|
width: 0px;
|
||||||
|
border-left: 1px dashed ${outlineColor};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ColumnHeaders = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
left: 0px;
|
||||||
|
top: 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
& .column-header {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RowResizeGuide = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
left: 0px;
|
||||||
|
height: 0px;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px dashed ${outlineColor};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AreaOutline = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid ${outlineColor};
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: ${outlineBackgroundColor};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CellOutline = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid ${outlineColor};
|
||||||
|
border-radius: 3px;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CellOutlineHandle = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: ${outlineColor};
|
||||||
|
cursor: crosshair;
|
||||||
|
// border: 1px solid white;
|
||||||
|
border-radius: 1px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ExtendToOutline = styled("div")`
|
||||||
|
position: absolute;
|
||||||
|
border: 1px dashed ${outlineColor};
|
||||||
|
border-radius: 3px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Worksheet;
|
||||||
16
webapp/src/fonts.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/* inter-regular - latin */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('fonts/inter-v13-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
|
/* inter-600 - latin */
|
||||||
|
@font-face {
|
||||||
|
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
src: url('fonts/inter-v13-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
|
}
|
||||||
BIN
webapp/src/fonts/inter-v13-latin-600.woff2
Normal file
BIN
webapp/src/fonts/inter-v13-latin-regular.woff2
Normal file
18
webapp/src/i18n.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
|
import translationEN from './locale/en_us.json';
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
'en-US': { translation: translationEN },
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources,
|
||||||
|
lng: 'en-US',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
14
webapp/src/icons/arrow-middle-from-line.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="arrow-middle-from-line" clip-path="url(#clip0_107_4135)">
|
||||||
|
<path id="Vector" d="M8 14.6667V10.6667" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_2" d="M8 5.33333V1.33333" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_3" d="M14.6667 8H1.33334" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_4" d="M10 12.6667L8 10.6667L6 12.6667" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path id="Vector_5" d="M10 3.33333L8 5.33333L6 3.33333" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_107_4135">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 869 B |
6
webapp/src/icons/border-bottom.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 14H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 8H14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 2V11.3333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 11.3333V3.33333C14 2.59695 13.403 2 12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V11.3333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 538 B |
4
webapp/src/icons/border-center-h.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 5.33333V3.33333C14 2.59695 13.403 2 12.6667 2H8M14 10.6667V12.6667C14 13.403 13.403 14 12.6667 14H8M2 10.6667V12.6667C2 13.403 2.59695 14 3.33333 14H8M2 5.33333V3.33333C2 2.59695 2.59695 2 3.33333 2H8M8 14V10.6667M8 2V5.33333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 8H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 498 B |
4
webapp/src/icons/border-center-v.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.6667 2H12.6667C13.403 2 14 2.59695 14 3.33333V8M5.33333 2H3.33333C2.59695 2 2 2.59695 2 3.33333V8M5.33333 14H3.33333C2.59695 14 2 13.403 2 12.6667V8M10.6667 14H12.6667C13.403 14 14 13.403 14 12.6667V8M2 8H5.33333M14 8H10.6667" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 2V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 498 B |
5
webapp/src/icons/border-inner.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 5.33333V3.33333C14 2.59695 13.403 2 12.6667 2H10.6667M14 10.6667V12.6667C14 13.403 13.403 14 12.6667 14H10.6667M2 10.6667V12.6667C2 13.403 2.59695 14 3.33333 14H5.33333M2 5.33333V3.33333C2 2.59695 2.59695 2 3.33333 2H5.33333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 8H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 2V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 586 B |
6
webapp/src/icons/border-left.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4.66667 2H12.6667C13.403 2 14 2.59695 14 3.33333V12.6667C14 13.403 13.403 14 12.6667 14H4.66667" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.66667 8H14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 2V14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 2V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 539 B |
5
webapp/src/icons/border-none.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 8H14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 2V14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 513 B |